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.
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.
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:
details structure 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.
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.validationErrors
Made 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.
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.
// ✅ 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.
// ✅ 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.
// ✅ 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.
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.
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.
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
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();
};
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);
});
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.
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 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.
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.
// __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.
// 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.
// __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.
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.
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.
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.