DEV Community

Renato Silva
Renato Silva

Posted on

Defending Your Domain: Schema Validation and Global Error Handling

Defending Your Domain: Schema Validation and Global Error Handling

In my previous post, I shared how Clean Architecture allowed me to swap my database layer in minutes. But architecture isn't just about being able to change parts; it's about protecting your system from the outside world.

Today, I added a "bouncer" to my API: Schema Validation.

The Problem: Silent Failures

Without validation, your Use Case might receive an empty name, a fake email, or a message that is too short. This leads to corrupted data in your database and unexpected "Internal Server Errors" (500) that give no clue to the user about what went wrong.

The Solution: Zod + Global Error Handler

I chose Zod for schema validation because it's type-safe, extremely popular in the modern Node.js ecosystem, and integrates perfectly with JavaScript.

1. Defining the Contract

Instead of just accepting any object, I defined exactly what a Feedback should look like. This contract ensures that the data follows our business rules before any processing happens:

// src/use-cases/submit-feedback-validator.js
const { z } = require('zod');

const submitFeedbackSchema = z.object({
  name: z.string().min(3, "Name must be at least 3 characters long"),
  email: z.string().email("Invalid email address"),
  message: z.string().min(10, "Message must be at least 10 characters long"),
});

module.exports = { submitFeedbackSchema };
Enter fullscreen mode Exit fullscreen mode

2. Future-Proof Error Handling

Libraries evolve fast. During implementation, I noticed that some built-in formatting methods like .format() or .flatten() were becoming deprecated or less flexible in the latest versions of Zod.

Instead of relying on unstable methods, I implemented a custom mapper to extract Zod's raw issues into a clean, developer-friendly response. By using a Global Error Handler in Fastify, I kept my routes clean and avoided repetitive try/catch blocks.

// src/server.js
fastify.setErrorHandler((error, request, reply) => {
  // Catching Zod validation errors
  if (error instanceof z.ZodError) {
    // Map raw issues into a clear field-to-message object
    const validationErrors = error.issues.reduce((acc, issue) => {
      const path = issue.path[0];
      acc[path] = issue.message;
      return acc;
    }, {});

    return reply.status(400).send({
      message: 'Validation failed.',
      errors: validationErrors,
    });
  }

  // Fallback for unexpected generic errors
  fastify.log.error(error);
  return reply.status(500).send({ message: 'Internal server error.' });
});
Enter fullscreen mode Exit fullscreen mode

The Result: Better UX and Cleaner Code
Now, if a user sends invalid data, the API doesn't just crash or return a generic error. It responds with a 400 Bad Request and a clear explanation of what needs to be fixed:

{
  "message": "Validation failed.",
  "errors": {
    "email": "Invalid email address",
    "message": "Message must be at least 10 characters long"
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways:

  • Fail Fast: Stop invalid data at the door before it touches your business logic or your database.

  • Global Handling: Centralize your error logic. This makes your application easier to maintain and keeps your controllers focused on the "happy path".

  • Be Adaptable: When library methods get deprecated, don't be afraid to write your own mappers. It gives you more control and makes your code more resilient to library updates.


What's next? Now that the API is safe and resilient, the next step is to ensure it stays working as we add new features. My next post will be about Automated Integration Testing.

Have you ever struggled with "dirty data" in your database? Let's talk about it in the comments!

Top comments (0)