Debugging a Silent Validation Failure in Production
How a small contract drift across backend, service, and UI layers silently broke validation, and what fixed it permanently.

I write technical articles that analyze real world engineering problems through the lens of production systems.
My work focuses on architectural tradeoffs, UI layer system design, performance optimization, and reliability patterns derived from shipping and maintaining scalable applications.
The Incident
I was testing a simple form submission. Invalid input. Expected inline validation feedback.
The backend returned a 400 error.
The UI showed nothing.
What followed wasn’t just a bug fix. It was a deep dive into how small contract violations across backend, service, and UI layers can silently break entire workflows.
The Investigation: Three Layers of Failure
Layer 1: Backend Contract Violation
Our API documentation promised this:
{
"success": false,
"error": "VALIDATION_ERROR",
"message": "Field is required",
"details": {
"parameter": "id",
"location": "path"
}
}
Clean. Flat. Frontend-friendly.
But the backend was actually returning this:
{
"success": false,
"error": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"validationErrors": [
{
"field": "fieldName",
"message": "Field is not in valid format"
}
]
}
}
What changed:
detailsstructure completely different (nested array vs flat object)Generic message instead of specific error
Stack traces included in the response (security concern)
Multiple validation errors aggregated (undocumented behavior)
The backend introduced a centralized validation layer that changed the error response shape. The frontend was parsing a structure that no longer existed.
First Principle: API contracts are agreements. Breaking them silently introduces systemic risk.
Layer 2: Service Layer Destruction
Here’s what our service layer was doing:
// ❌ THE BUG
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Failed to save: ${response.status} ${errorBody}`
);
}
This code:
Converted backend JSON → string
Wrapped it in
new Error()Destroyed
details.validationErrorsMade structured data unparseable
By the time the error reached the form component:
Error: Failed to save: 400 {"success":false,"error":"VALIDATION_ERROR"...}
There was no details object. No validationErrors. Just a stringified mess.
Why this happened: We were using fetch, which does not automatically parse error responses. We had to do it manually, and we did it wrong.
Second Principle: Validation errors are data, not exceptions. Treat them like API responses, not crashes.
Layer 3: Parent Component Swallowing Errors
Even after fixing the service layer, errors weren’t reaching the form. Here’s why:
// ❌ PARENT COMPONENT
catch (error) {
console.error("Save error:", error);
showToast("error", "Error", "Failed to save. Please retry.");
// ERROR HANDLED BUT NOT RETHROWN
}
The parent component was:
Catching the error
Logging it
Showing a generic toast
Not rethrowing it
The child component (the actual form) never received the validation details. Field highlighting couldn’t work because the form didn’t know what was invalid.
Third Principle: Parents call APIs. Children render validation. Errors must bubble, not be swallowed.
The Fix: Three Surgical Strikes
Fix 1: Service Layer Must Preserve Structure
// ✅ CORRECT
export const saveResource = async (
accessToken: string | null,
payload: Record<string, any>
): Promise<ResourceType> => {
const response = await fetch(`${API_URL}/resource`, {
method: "POST",
headers: makeHeaders(accessToken || undefined),
credentials: "include",
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorJson = await response.json(); // 👈 KEEP STRUCTURE
throw errorJson; // 👈 THROW THE OBJECT, NOT A STRING
}
return response.json();
};
Why this works: The error object stays intact. details.validationErrors survives. Frontend can map fields to errors.
Fix 2: Parent Must Rethrow Backend Errors
// ✅ CORRECT
const handleSave = async (data: ResourceType) => {
try {
await saveResource(accessToken, data);
showToast("success", "Success", "Saved successfully");
onSuccess();
} catch (error: any) {
console.error("Save error:", error);
// 🔥 ONLY SHOW TOAST FOR NON-VALIDATION ERRORS
if (error?.error !== "VALIDATION_ERROR") {
showToast("error", "Error", "Failed to save. Please retry.");
}
// 🔥 RETHROW SO CHILD CAN HANDLE FIELD-LEVEL ERRORS
throw error?.response?.data || error;
} finally {
setLoading(false);
}
};
Why this works: Parent handles infrastructure concerns (toasts, loading). Child handles validation concerns (field highlighting). Separation of concerns.
Fix 3: Child Maps Backend Errors to Form State
// ✅ CHILD COMPONENT
const handleBackendErrors = (error: any) => {
const validationErrors = error?.details?.validationErrors;
if (!validationErrors || !Array.isArray(validationErrors)) {
return;
}
const newErrors: Record<string, string> = {};
validationErrors.forEach((err: { field: string; message: string }) => {
newErrors[err.field] = err.message;
});
setErrors((prev) => ({ ...prev, ...newErrors }));
};
const handleSubmit = async () => {
// Frontend validation first
if (!validateForm()) return;
try {
await onSave(formData);
} catch (error: any) {
handleBackendErrors(error); // 👈 MAPS FIELDS TO ERRORS
}
};
Why this works: Backend is the source of truth. Frontend displays backend’s decision. No duplication of validation logic.
The System Flow (Correct Architecture)
User submits form
↓
Frontend basic validation (required fields only)
↓
Backend authoritative validation (business rules + format checks)
↓
Service throws JSON error (preserving structure)
↓
Parent rethrows error (after logging/toast)
↓
Form component maps field → error message
↓
Input component highlights and displays message
↓
User fixes and resubmits

Fourth Principle: Validation is a pipeline, not a wall. Frontend filters junk. Backend enforces truth.
The Deeper Lessons
1. API Contracts Are Not Suggestions
When the backend introduced aggregated validation errors, the team should have:
Updated the API documentation
Versioned the endpoint (
/v2/resource)Announced the breaking change
Given the frontend team migration time
Instead, the contract changed silently. This is how teams slow down and trust erodes.
Recommendation: Use OpenAPI/Swagger with contract testing. If response shape changes, CI fails.
2. Error Handling Is Infrastructure, Not Implementation
We made three mistakes:
Service layer destroyed structured data
Parent component swallowed errors
Child component had no way to receive details
The fix required changes at all three layers. This is why error handling must be designed upfront, not bolted on.
Recommendation: Establish error handling patterns in your design system:
// Standard error interface
interface ApiError {
success: false;
error: string;
message: string;
details?: Record<string, any>;
}
// Standard service layer contract
throw errorJson; // Never wrap in Error()
// Standard parent contract
throw error; // Always rethrow
// Standard child contract
handleBackendErrors(error); // Map to UI state
3. Fetch Requires Manual Error Parsing
Axios automatically parses JSON errors and attaches them to error.response.data. Fetch does not.
With fetch, you must manually:
const errorJson = await response.json();
throw errorJson;
We forgot this. It broke everything.
Recommendation: Create a wrapper if using fetch:
export const fetchJson = async (url: string, options?: RequestInit) => {
const response = await fetch(url, options);
if (!response.ok) {
const errorJson = await response.json();
throw errorJson;
}
return response.json();
};
4. Stack Traces Belong in Logs, Not API Responses
The response also included internal stack trace information, which should never be exposed to clients. This reveals server file structure, implementation details, and potential attack surface.
Recommendation: Use middleware to strip stack traces in production:
// Express example
app.use((err, req, res, next) => {
const response = {
success: false,
error: err.name,
message: err.message,
details: err.details,
};
// Only include stack in development
if (process.env.NODE_ENV !== "production") {
response.stack = err.stack;
}
res.status(err.statusCode || 500).json(response);
});
Alternative Approaches
Centralized Error Normalizer
Create a single error parser that works with any API format:
export const normalizeError = (error: any): ApiError => {
// Axios format
if (error?.response?.data) {
return error.response.data;
}
// Fetch format
if (error?.details) {
return error;
}
// Generic Error
return {
success: false,
error: "UNKNOWN_ERROR",
message: error?.message || "Something went wrong",
};
};
Pros: Works with any backend. Survives API changes.
Cons: Adds indirection. Requires team discipline.
Schema Validation
Use Zod/Yup to validate API responses:
import { z } from "zod";
const ApiErrorSchema = z.object({
success: z.literal(false),
error: z.string(),
message: z.string(),
details: z.object({
validationErrors: z.array(
z.object({
field: z.string(),
message: z.string(),
})
),
}).optional(),
});
// Service layer
if (!response.ok) {
const errorJson = await response.json();
const validated = ApiErrorSchema.parse(errorJson); // 👈 THROWS IF SHAPE WRONG
throw validated;
}
Pros: Contract violations fail loudly. Backend can’t break you silently.
Cons: Adds bundle size. Requires maintenance.
Other Patterns
Other approaches exist depending on team size and complexity:
Error Context (React): Global context for field errors, useful for cross-route validation
Backend-Driven UI: Let backend specify which fields to highlight, useful when backend owns UX logic
The key is choosing an approach that fits your team’s scale and maintaining it consistently.
What I’d Do Differently Next Time
1. Write the Error Contract First
Before writing any form code:
// errors.types.ts
export interface ValidationError {
success: false;
error: "VALIDATION_ERROR";
message: string;
details: {
validationErrors: Array<{
field: string;
message: string;
}>;
};
}
Lock this in. Frontend and backend sign it.
2. Add Contract Tests
// __tests__/api.contract.test.ts
test("POST /resource returns correct error shape on validation failure", async () => {
const response = await fetch("/resource", {
method: "POST",
body: JSON.stringify({ field: "invalid" }),
});
const error = await response.json();
expect(error).toMatchObject({
success: false,
error: "VALIDATION_ERROR",
details: {
validationErrors: expect.any(Array),
},
});
expect(error).not.toHaveProperty("stack"); // 👈 NO LEAKS
});
This test breaks the moment the backend changes the contract.
3. Document Error Handling in Code
// services/resource.service.ts
/**
* IMPORTANT: Backend validation errors MUST be thrown as parsed JSON.
*
* ❌ DO NOT: throw new Error(JSON.stringify(error))
* ✅ DO THIS: throw errorJson
*
* This preserves the structure needed for field-level error display.
*/
export const saveResource = async (...) => {
if (!response.ok) {
const errorJson = await response.json();
throw errorJson; // 👈 PRESERVES details.validationErrors
}
return response.json();
};
Future developers will thank you.
4. Create a Regression Test
// __tests__/Form.test.tsx
test("displays backend validation errors on fields", async () => {
mockApi.post("/resource").mockRejectedValue({
success: false,
error: "VALIDATION_ERROR",
details: {
validationErrors: [
{ field: "fieldName", message: "Invalid format" },
],
},
});
render(<Form />);
fireEvent.change(screen.getByLabelText("Field"), {
target: { value: "invalid" },
});
fireEvent.click(screen.getByText("Submit"));
await waitFor(() => {
expect(screen.getByText("Invalid format")).toBeInTheDocument();
});
});
If this test passes, your error pipeline works end-to-end.
The Mental Model That Saves You
Ask yourself one question:
If the backend adds a new validation error tomorrow, does my frontend require code changes?
If the answer is yes, your architecture is fragile.
If the answer is no, you’ve built it correctly.
Our fixed system:
Backend adds new field validation → Zero frontend changes
Field automatically highlights
Error message automatically displays
No deploys needed
That’s the goal.
Final Principles
API contracts are agreements. Breaking them silently introduces systemic risk.
Validation errors are data. Never stringify. Never wrap in
Error().Parents handle infrastructure. Children handle validation.
Errors must bubble. Swallowing them creates silent failures.
Backend is the source of truth. Frontend displays, doesn’t duplicate.
Stack traces are logs. Never send them to clients.
Design error handling first. Bolting it on later breaks everything.
Conclusion
This bug taught me that system design failures are invisible until they’re catastrophic.
The backend changed a contract. The service layer destroyed data. The parent component swallowed errors. Each violation was small. Together, they killed the feature.
The fix required three precise changes across three layers. But the real fix was understanding the principles that prevent this class of bug forever.
If you’re building form-driven applications, you’ll face this. When you do:
Check the contract first
Preserve error structure
Let errors bubble
Map backend fields to UI state
And most importantly: write the test that proves it works end-to-end.
Because the next person who changes the validation logic won’t remember why it broke last time.
But your tests will.
Written after debugging a validation error pipeline and learning why contracts, structure preservation, and error bubbling are non-negotiable in production systems.
Note: This article was originally published on Medium and is republished here for wider access and long-term reference.




