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.

Updated
•9 min read•View as Markdown
Debugging a Silent Validation Failure in Production
J

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:

  1. details structure completely different (nested array vs flat object)

  2. Generic message instead of specific error

  3. Stack traces included in the response (security concern)

  4. 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.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.

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:

  1. Service layer destroyed structured data

  2. Parent component swallowed errors

  3. 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

  1. API contracts are agreements. Breaking them silently introduces systemic risk.

  2. Validation errors are data. Never stringify. Never wrap in Error().

  3. Parents handle infrastructure. Children handle validation.

  4. Errors must bubble. Swallowing them creates silent failures.

  5. Backend is the source of truth. Frontend displays, doesn’t duplicate.

  6. Stack traces are logs. Never send them to clients.

  7. 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.