Error Handling Terpusat dan Validasi Data - Perwira Learning Center


1. Latar Belakang

    Memasuki minggu keempat, hari keempat belajar Express.js di Perwira Learning Center kita akan membahas Error Handling Terpusat dan Validasi Data. Ini adalah sistem keamanan dan kontrol kualitas dalam aplikasi:

Analogi Pabrik Mobil:

  • Validasi Data = Pemeriksaan kualitas bahan baku sebelum masuk jalur produksi 
    • "Apakah bahan baku sesuai spesifikasi?"
    • Cek dimensi, kekerasan, komposisi material
    • Tolak bahan yang tidak memenuhi standar
  • Error Handling Terpusat = Pusat kendali masalah di pabrik"
    • Ada masalah di jalur produksi, bagaimana menanganinya?"
    • Klasifikasi error (ringan, sedang, kritis)
    • Prosedur penanganan standar untuk setiap jenis error
    • Dokumentasi semua insiden

Mengapa Error Handling Terpusat?

text
┌─────────────────────────────────────────────────────────┐
│                    TANPA TERPUSAT                       │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Controller 1: try/catch → res.status(400).json(...)   │
│  Controller 2: if(error) → res.status(500).send(...)   │
│  Controller 3: throw error → crash!                   │
│  Controller 4: console.log → res.json({err: true})     │
│                                                         │
│  ❌ INKONSISTEN ❌ TIDAK TERSTRUKTUR ❌ SULIT DEBUG    │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    DENGAN TERPUSAT                      │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Controller 1: throw new ValidationError(...)          │
│  Controller 2: throw new DatabaseError(...)            │
│  Controller 3: throw new NotFoundError(...)            │
│  Controller 4: throw new AuthError(...)               │
│                    ↓                                    │
│          ┌─────────────────────┐                       │
│          │   ERROR HANDLER     │                       │
│          │     TERPUSAT        │                       │
│          └─────────────────────┘                       │
│                    ↓                                    │
│  ✅ KONSISTEN ✅ TERSTRUKTUR ✅ MUDAH DEBUG           │
└─────────────────────────────────────────────────────────┘

2. Alat dan Bahan

a. Perangkat Lunak

  1. Node.js & npm - Runtime dan package manager
  2. Express.js - Framework utama
  3. Joi - Schema validation library
  4. express-validator - Validasi untuk Express
  5. http-errors - Generate HTTP errors
  6. celebrate - Joi integration untuk Express
  7. winston - Logging library
  8. Postman - Testing error scenarios

b. Perangkat Keras

  • Laptop/PC dengan spesifikasi standar

3. Pembahasan

3.1 Arsitektur Error Handling Terpusat

javascript
/**
 * ARSITEKTUR ERROR HANDLING TERPUSAT
 * 
 * ┌─────────────────┐
 * │    REQUEST      │
 * └────────┬────────┘
 *          │
 * ┌────────▼────────┐
 * │   VALIDATION    │ ◄────┐
 * │   MIDDLEWARE    │      │
 * └────────┬────────┘      │
 *          │                │
 * ┌────────▼────────┐      │
 * │   CONTROLLER    │      │ Error
 * └────────┬────────┘      │ Thrown
 *          │                │
 * ┌────────▼────────┐      │
 * │    SERVICE      │      │
 * └────────┬────────┘      │
 *          │                │
 * ┌────────▼────────┐      │
 * │   REPOSITORY    │      │
 * └────────┬────────┘      │
 *          │                │
 *          ▼                │
 *    ┌─────────┐           │
 *    │ SUCCESS │           │
 *    └─────────┘           │
 *          │                │
 *          ▼                ▼
 *    ┌─────────────────────────┐
 *    │   GLOBAL ERROR HANDLER  │
 *    └───────────┬─────────────┘
 *                │
 *                ├────► Log Error
 *                ├────► Classify Error
 *                ├────► Format Response
 *                └────► Send Response
 * 
 * ┌─────────────────────────────────────────────────────┐
 * │           FORMAT RESPONSE ERROR                    │
 * ├─────────────────────────────────────────────────────┤
 * │ {                                                   │
 * │   "success": false,                                │
 * │   "error": {                                       │
 * │     "code": "VALIDATION_ERROR",                   │
 * │     "message": "Validasi data gagal",             │
 * │     "details": [...],                             │
 * │     "timestamp": "2026-02-12T10:30:00Z",          │
 * │     "path": "/api/users",                         │
 * │     "requestId": "req_123456"                     │
 * │   }                                               │
 * │ }                                                 │
 * └─────────────────────────────────────────────────────┘
 */

3.2 Praktik Lengkap: Error Handling & Validation System

Mari kita bangun sistem error handling dan validasi yang komprehensif:

bash
# 1. Buat folder project
mkdir error-handling-system
cd error-handling-system

# 2. Inisialisasi project
npm init -y

# 3. Install dependencies
npm install express dotenv cors helmet morgan compression
npm install joi celebrate express-validator
npm install http-errors boom
npm install winston winston-daily-rotate-file
npm install uuid
npm install -D nodemon

# 4. Buat struktur folder
mkdir -p src/{errors,middleware,validators,controllers,services,routes,config,utils,models}

A. Custom Error Classes

src/errors/AppError.js

javascript
/**
 * CUSTOM ERROR CLASSES
 * Base class untuk semua error dalam aplikasi
 */

class AppError extends Error {
  constructor(message, statusCode, errorCode, details = null) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode || 500;
    this.errorCode = errorCode || 'INTERNAL_ERROR';
    this.details = details;
    this.timestamp = new Date().toISOString();
    this.isOperational = true; // Operational error (vs programming error)
    
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      success: false,
      error: {
        code: this.errorCode,
        message: this.message,
        details: this.details,
        timestamp: this.timestamp
      }
    };
  }
}

// ============= 4xx Client Errors =============

class ValidationError extends AppError {
  constructor(message = 'Validasi data gagal', details = null) {
    super(message, 400, 'VALIDATION_ERROR', details);
  }
}

class BadRequestError extends AppError {
  constructor(message = 'Bad request', details = null) {
    super(message, 400, 'BAD_REQUEST', details);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized access', details = null) {
    super(message, 401, 'UNAUTHORIZED', details);
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Forbidden access', details = null) {
    super(message, 403, 'FORBIDDEN', details);
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource', id = null) {
    const message = id 
      ? `${resource} dengan ID ${id} tidak ditemukan`
      : `${resource} tidak ditemukan`;
    super(message, 404, 'NOT_FOUND', { resource, id });
  }
}

class ConflictError extends AppError {
  constructor(message = 'Resource conflict', details = null) {
    super(message, 409, 'CONFLICT', details);
  }
}

class TooManyRequestsError extends AppError {
  constructor(message = 'Too many requests', retryAfter = 60) {
    super(message, 429, 'TOO_MANY_REQUESTS', { retryAfter });
    this.retryAfter = retryAfter;
  }
}

// ============= 5xx Server Errors =============

class DatabaseError extends AppError {
  constructor(message = 'Database operation failed', originalError = null) {
    super(message, 503, 'DATABASE_ERROR', {
      original: originalError?.message,
      code: originalError?.code
    });
  }
}

class ExternalServiceError extends AppError {
  constructor(service = 'External service', message = 'Service unavailable') {
    super(`${service}: ${message}`, 502, 'EXTERNAL_SERVICE_ERROR', { service });
  }
}

class InternalServerError extends AppError {
  constructor(message = 'Internal server error', originalError = null) {
    super(message, 500, 'INTERNAL_SERVER_ERROR', {
      original: originalError?.message
    });
    this.isOperational = false; // Programming error
  }
}

// ============= Business Logic Errors =============

class BusinessError extends AppError {
  constructor(message, code = 'BUSINESS_ERROR', statusCode = 422, details = null) {
    super(message, statusCode, code, details);
  }
}

class InsufficientStockError extends BusinessError {
  constructor(productId, requested, available) {
    super(
      `Stok tidak mencukupi untuk produk ${productId}`,
      'INSUFFICIENT_STOCK',
      422,
      { productId, requested, available }
    );
  }
}

class DuplicateEntryError extends BusinessError {
  constructor(field, value, resource = 'Resource') {
    super(
      `${resource} dengan ${field} '${value}' sudah terdaftar`,
      'DUPLICATE_ENTRY',
      409,
      { field, value, resource }
    );
  }
}

module.exports = {
  AppError,
  ValidationError,
  BadRequestError,
  UnauthorizedError,
  ForbiddenError,
  NotFoundError,
  ConflictError,
  TooManyRequestsError,
  DatabaseError,
  ExternalServiceError,
  InternalServerError,
  BusinessError,
  InsufficientStockError,
  DuplicateEntryError
};

B. Error Handler Middleware Terpusat

src/middleware/errorHandler.middleware.js

javascript
/**
 * GLOBAL ERROR HANDLER MIDDLEWARE
 * Menangani semua error secara terpusat
 */

const { v4: uuidv4 } = require('uuid');
const logger = require('../utils/logger');
const config = require('../config/env');
const {
  AppError,
  ValidationError,
  DatabaseError,
  NotFoundError
} = require('../errors/AppError');

class ErrorHandlerMiddleware {
  constructor() {
    this.isDevelopment = config.NODE_ENV === 'development';
    this.isTest = config.NODE_ENV === 'test';
  }

  /**
   * 404 Not Found Handler
   * Middleware untuk route yang tidak ditemukan
   */
  notFoundHandler() {
    return (req, res, next) => {
      const error = new NotFoundError(
        'Route',
        `${req.method} ${req.originalUrl}`
      );
      next(error);
    };
  }

  /**
   * Joi Validation Error Handler
   * Mengubah error Joi menjadi ValidationError
   */
  joiErrorHandler() {
    return (err, req, res, next) => {
      if (err && err.isJoi) {
        const details = err.details.map(detail => ({
          field: detail.path.join('.'),
          message: detail.message.replace(/"/g, ''),
          type: detail.type,
          context: detail.context
        }));

        const validationError = new ValidationError(
          'Validasi data gagal',
          details
        );
        return next(validationError);
      }
      next(err);
    };
  }

  /**
   * Celebrate Validation Error Handler
   * Untuk error dari library celebrate
   */
  celebrateErrorHandler() {
    return (err, req, res, next) => {
      if (err && err.joi) {
        const details = err.joi.details.map(detail => ({
          field: detail.path.join('.'),
          message: detail.message.replace(/"/g, ''),
          type: detail.type
        }));

        const validationError = new ValidationError(
          'Validasi data gagal',
          details
        );
        return next(validationError);
      }
      next(err);
    };
  }

  /**
   * MongoDB/Mongoose Error Handler
   * Mengubah error database menjadi DatabaseError
   */
  databaseErrorHandler() {
    return (err, req, res, next) => {
      // MongoDB duplicate key error
      if (err.code === 11000) {
        const field = Object.keys(err.keyPattern)[0];
        const value = err.keyValue[field];
        
        const duplicateError = new ValidationError(
          `Duplicate value for ${field}`,
          [{
            field,
            message: `${field} dengan nilai '${value}' sudah digunakan`,
            type: 'duplicate_key'
          }]
        );
        return next(duplicateError);
      }

      // MongoDB validation error
      if (err.name === 'ValidationError') {
        const details = Object.values(err.errors).map(e => ({
          field: e.path,
          message: e.message,
          type: e.kind
        }));

        const validationError = new ValidationError(
          'Database validation failed',
          details
        );
        return next(validationError);
      }

      // MongoDB cast error (invalid ObjectId)
      if (err.name === 'CastError') {
        const notFoundError = new NotFoundError(
          err.model?.modelName || 'Resource',
          err.value
        );
        return next(notFoundError);
      }

      // Other database errors
      if (err.name === 'MongoError' || err.name === 'MongooseError') {
        const dbError = new DatabaseError(err.message, err);
        return next(dbError);
      }

      next(err);
    };
  }

  /**
   * HTTP Error Handler
   * Mengubah error dari library http-errors
   */
  httpErrorHandler() {
    return (err, req, res, next) => {
      if (err.statusCode) {
        const error = new AppError(
          err.message,
          err.statusCode,
          err.code || 'HTTP_ERROR',
          err.details
        );
        return next(error);
      }
      next(err);
    };
  }

  /**
   * Multer Error Handler
   * Mengubah error upload file
   */
  multerErrorHandler() {
    return (err, req, res, next) => {
      if (err.name === 'MulterError') {
        let message = 'File upload error';
        let code = 'UPLOAD_ERROR';

        switch (err.code) {
          case 'LIMIT_FILE_SIZE':
            message = 'File terlalu besar';
            code = 'FILE_TOO_LARGE';
            break;
          case 'LIMIT_FILE_COUNT':
            message = 'Terlalu banyak file';
            code = 'TOO_MANY_FILES';
            break;
          case 'LIMIT_UNEXPECTED_FILE':
            message = 'Field file tidak valid';
            code = 'INVALID_FILE_FIELD';
            break;
        }

        const uploadError = new AppError(message, 400, code, {
          field: err.field,
          code: err.code
        });
        return next(uploadError);
      }
      next(err);
    };
  }

  /**
   * Async Error Wrapper
   * Wrapper untuk async route handlers
   */
  static asyncHandler(fn) {
    return (req, res, next) => {
      Promise.resolve(fn(req, res, next)).catch(next);
    };
  }

  /**
   * MAIN ERROR HANDLER
   * Semua error akan berakhir di sini
   */
  mainErrorHandler() {
    return (err, req, res, next) => {
      // Generate request ID if not exists
      req.requestId = req.requestId || uuidv4();

      // Ensure error is AppError instance
      if (!(err instanceof AppError)) {
        err = new AppError(
          err.message || 'Internal server error',
          err.statusCode || 500,
          err.code || 'INTERNAL_ERROR',
          this.isDevelopment ? { stack: err.stack } : null
        );
      }

      // Log error
      this.logError(err, req);

      // Prepare error response
      const errorResponse = this.formatErrorResponse(err, req);

      // Send response
      res.status(err.statusCode || 500).json(errorResponse);
    };
  }

  /**
   * Format error response
   */
  formatErrorResponse(err, req) {
    const response = {
      success: false,
      error: {
        code: err.errorCode || err.code || 'INTERNAL_ERROR',
        message: err.message,
        timestamp: err.timestamp || new Date().toISOString(),
        path: req.originalUrl,
        method: req.method,
        requestId: req.requestId
      }
    };

    // Add details if exists
    if (err.details) {
      response.error.details = err.details;
    }

    // Add stack trace in development
    if (this.isDevelopment && err.stack) {
      response.error.stack = err.stack;
      response.error.fullError = err;
    }

    // Add validation errors specifically
    if (err instanceof ValidationError && err.details) {
      response.error.validation = err.details;
    }

    // Add retry after for rate limiting
    if (err.retryAfter) {
      response.error.retryAfter = err.retryAfter;
    }

    return response;
  }

  /**
   * Log error dengan konteks lengkap
   */
  logError(err, req) {
    const logData = {
      requestId: req.requestId,
      userId: req.user?.id,
      email: req.user?.email,
      method: req.method,
      url: req.originalUrl,
      ip: req.ip || req.connection.remoteAddress,
      userAgent: req.headers['user-agent'],
      statusCode: err.statusCode,
      errorCode: err.errorCode,
      message: err.message,
      stack: err.stack,
      details: err.details
    };

    // Log berdasarkan severity
    if (err.statusCode >= 500) {
      logger.error('Server Error:', logData);
    } else if (err.statusCode >= 400) {
      logger.warn('Client Error:', logData);
    } else {
      logger.info('Error:', logData);
    }

    // Log to separate file for errors only
    if (err.statusCode >= 500) {
      logger.errorToFile(logData);
    }
  }

  /**
   * Unhandled Rejection Handler
   */
  static unhandledRejectionHandler() {
    process.on('unhandledRejection', (reason, promise) => {
      logger.error('Unhandled Rejection at:', {
        promise,
        reason: reason?.stack || reason,
        timestamp: new Date().toISOString()
      });

      // Don't exit in production, but log extensively
      if (process.env.NODE_ENV === 'development') {
        process.exit(1);
      }
    });
  }

  /**
   * Uncaught Exception Handler
   */
  static uncaughtExceptionHandler() {
    process.on('uncaughtException', (error) => {
      logger.error('Uncaught Exception:', {
        error: error.stack || error,
        timestamp: new Date().toISOString()
      });

      // Always exit on uncaught exception
      process.exit(1);
    });
  }
}

module.exports = new ErrorHandlerMiddleware();

C. Advanced Validation System

src/validators/BaseValidator.js

javascript
/**
 * BASE VALIDATOR
 * Foundation untuk semua validator
 */

const Joi = require('joi');

class BaseValidator {
  constructor() {
    this.joi = Joi;
  }

  /**
   * Common validation patterns
   */
  patterns = {
    email: Joi.string()
      .email()
      .lowercase()
      .trim()
      .max(255)
      .messages({
        'string.email': 'Format email tidak valid',
        'string.empty': 'Email wajib diisi',
        'string.max': 'Email maksimal 255 karakter'
      }),

    password: Joi.string()
      .min(8)
      .max(100)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
      .messages({
        'string.min': 'Password minimal 8 karakter',
        'string.max': 'Password maksimal 100 karakter',
        'string.pattern.base': 'Password harus mengandung huruf besar, huruf kecil, angka, dan karakter khusus',
        'string.empty': 'Password wajib diisi'
      }),

    name: Joi.string()
      .min(2)
      .max(100)
      .trim()
      .pattern(/^[a-zA-Z\s'-]+$/)
      .messages({
        'string.min': 'Nama minimal 2 karakter',
        'string.max': 'Nama maksimal 100 karakter',
        'string.pattern.base': 'Nama hanya boleh mengandung huruf, spasi, tanda petik, dan strip',
        'string.empty': 'Nama wajib diisi'
      }),

    phone: Joi.string()
      .pattern(/^(\+62|62|0)[2-9][0-9]{7,11}$/)
      .messages({
        'string.pattern.base': 'Format nomor telepon tidak valid',
        'string.empty': 'Nomor telepon wajib diisi'
      }),

    id: Joi.number()
      .integer()
      .positive()
      .messages({
        'number.base': 'ID harus berupa angka',
        'number.integer': 'ID harus bilangan bulat',
        'number.positive': 'ID harus positif'
      }),

    uuid: Joi.string()
      .uuid()
      .messages({
        'string.uuid': 'Format UUID tidak valid',
        'string.empty': 'UUID wajib diisi'
      }),

    boolean: Joi.boolean()
      .messages({
        'boolean.base': 'Nilai harus boolean (true/false)'
      }),

    date: Joi.date()
      .iso()
      .messages({
        'date.base': 'Format tanggal tidak valid',
        'date.iso': 'Gunakan format ISO (YYYY-MM-DD)'
      }),

    url: Joi.string()
      .uri()
      .messages({
        'string.uri': 'Format URL tidak valid',
        'string.empty': 'URL wajib diisi'
      })
  };

  /**
   * Pagination validator
   */
  pagination = Joi.object({
    page: Joi.number()
      .integer()
      .min(1)
      .default(1)
      .messages({
        'number.base': 'Page harus berupa angka',
        'number.integer': 'Page harus bilangan bulat',
        'number.min': 'Page minimal 1'
      }),
    
    limit: Joi.number()
      .integer()
      .min(1)
      .max(100)
      .default(10)
      .messages({
        'number.base': 'Limit harus berupa angka',
        'number.integer': 'Limit harus bilangan bulat',
        'number.min': 'Limit minimal 1',
        'number.max': 'Limit maksimal 100'
      }),
    
    sort: Joi.string()
      .pattern(/^[a-zA-Z0-9_]+:(asc|desc)$/)
      .default('createdAt:desc')
      .messages({
        'string.pattern.base': 'Format sort harus "field:asc" atau "field:desc"'
      }),
    
    search: Joi.string()
      .trim()
      .max(100)
      .allow('', null)
  });

  /**
   * Date range validator
   */
  dateRange = Joi.object({
    startDate: Joi.date().iso(),
    endDate: Joi.date().iso().min(Joi.ref('startDate')),
    period: Joi.string()
      .valid('day', 'week', 'month', 'year', 'custom')
      .messages({
        'any.only': 'Period harus day, week, month, year, atau custom'
      })
  }).custom((value, helpers) => {
    if (value.period === 'custom' && (!value.startDate || !value.endDate)) {
      return helpers.error('dateRange.period.custom.required');
    }
    return value;
  }, 'Date range validation').messages({
    'dateRange.period.custom.required': 'Untuk custom period, startDate dan endDate wajib diisi'
  });

  /**
   * Base validate method
   */
  validate(schema, data, options = {}) {
    const { 
      abortEarly = false,
      stripUnknown = true,
      allowUnknown = false,
      convert = true
    } = options;

    return schema.validate(data, {
      abortEarly,
      stripUnknown,
      allowUnknown,
      convert
    });
  }

  /**
   * Async validate with custom error handling
   */
  async validateAsync(schema, data, options = {}) {
    try {
      const value = await schema.validateAsync(data, {
        abortEarly: false,
        stripUnknown: true,
        ...options
      });
      return { value, error: null };
    } catch (error) {
      return { value: null, error };
    }
  }

  /**
   * Create custom validator
   */
  createValidator(validatorFn, message) {
    return Joi.custom((value, helpers) => {
      const isValid = validatorFn(value);
      if (!isValid) {
        return helpers.error('any.custom', { message });
      }
      return value;
    }, 'Custom validation');
  }
}

module.exports = BaseValidator;

src/validators/UserValidator.js

javascript
/**
 * USER VALIDATOR
 * Validasi untuk semua operasi terkait user
 */

const Joi = require('joi');
const BaseValidator = require('./BaseValidator');

class UserValidator extends BaseValidator {
  constructor() {
    super();
  }

  /**
   * Register validation schema
   */
  register = Joi.object({
    email: this.patterns.email.required(),
    password: this.patterns.password.required(),
    name: this.patterns.name.required(),
    phone: this.patterns.phone.optional(),
    birthDate: this.patterns.date.max('now').optional(),
    address: Joi.object({
      street: Joi.string().max(200).optional(),
      city: Joi.string().max(100).optional(),
      province: Joi.string().max(100).optional(),
      postalCode: Joi.string().pattern(/^\d{5}$/).optional(),
      country: Joi.string().default('Indonesia')
    }).optional(),
    role: Joi.string()
      .valid('user', 'moderator')
      .default('user')
      .messages({
        'any.only': 'Role harus user atau moderator'
      })
  });

  /**
   * Login validation schema
   */
  login = Joi.object({
    email: this.patterns.email.required(),
    password: Joi.string().required().messages({
      'string.empty': 'Password wajib diisi'
    }),
    rememberMe: Joi.boolean().default(false)
  });

  /**
   * Update profile validation schema
   */
  updateProfile = Joi.object({
    name: this.patterns.name.optional(),
    phone: this.patterns.phone.optional(),
    birthDate: this.patterns.date.max('now').optional(),
    address: Joi.object({
      street: Joi.string().max(200).optional(),
      city: Joi.string().max(100).optional(),
      province: Joi.string().max(100).optional(),
      postalCode: Joi.string().pattern(/^\d{5}$/).optional(),
      country: Joi.string().optional()
    }).optional(),
    avatar: Joi.string().uri().optional()
  }).min(1).messages({
    'object.min': 'Minimal satu field harus diupdate'
  });

  /**
   * Change password validation schema
   */
  changePassword = Joi.object({
    currentPassword: Joi.string().required().messages({
      'string.empty': 'Password saat ini wajib diisi'
    }),
    newPassword: this.patterns.password.required(),
    confirmPassword: Joi.string()
      .valid(Joi.ref('newPassword'))
      .required()
      .messages({
        'any.only': 'Konfirmasi password tidak cocok',
        'string.empty': 'Konfirmasi password wajib diisi'
      })
  });

  /**
   * Forgot password validation schema
   */
  forgotPassword = Joi.object({
    email: this.patterns.email.required()
  });

  /**
   * Reset password validation schema
   */
  resetPassword = Joi.object({
    token: Joi.string().required().messages({
      'string.empty': 'Token reset password wajib diisi'
    }),
    newPassword: this.patterns.password.required(),
    confirmPassword: Joi.string()
      .valid(Joi.ref('newPassword'))
      .required()
      .messages({
        'any.only': 'Konfirmasi password tidak cocok',
        'string.empty': 'Konfirmasi password wajib diisi'
      })
  });

  /**
   * User ID param validator
   */
  userIdParam = Joi.object({
    id: this.patterns.id.required()
  });

  /**
   * Query users validator
   */
  queryUsers = Joi.object({
    ...this.pagination,
    role: Joi.string()
      .valid('user', 'moderator', 'admin')
      .optional(),
    isActive: Joi.boolean().optional(),
    isEmailVerified: Joi.boolean().optional(),
    search: Joi.string().trim().max(100).optional()
  });
}

module.exports = new UserValidator();

src/validators/ProductValidator.js

javascript
/**
 * PRODUCT VALIDATOR
 * Validasi untuk semua operasi terkait produk
 */

const Joi = require('joi');
const BaseValidator = require('./BaseValidator');

class ProductValidator extends BaseValidator {
  constructor() {
    super();
  }

  /**
   * Product categories
   */
  categories = [
    'electronics',
    'fashion',
    'home',
    'sports',
    'books',
    'automotive',
    'health',
    'beauty',
    'food',
    'other'
  ];

  /**
   * Create product validation schema
   */
  create = Joi.object({
    name: Joi.string()
      .min(3)
      .max(200)
      .required()
      .messages({
        'string.min': 'Nama produk minimal 3 karakter',
        'string.max': 'Nama produk maksimal 200 karakter',
        'string.empty': 'Nama produk wajib diisi'
      }),

    description: Joi.string()
      .min(10)
      .max(5000)
      .required()
      .messages({
        'string.min': 'Deskripsi produk minimal 10 karakter',
        'string.max': 'Deskripsi produk maksimal 5000 karakter',
        'string.empty': 'Deskripsi produk wajib diisi'
      }),

    price: Joi.number()
      .positive()
      .precision(2)
      .required()
      .messages({
        'number.base': 'Harga harus berupa angka',
        'number.positive': 'Harga harus lebih dari 0',
        'any.required': 'Harga wajib diisi'
      }),

    stock: Joi.number()
      .integer()
      .min(0)
      .default(0)
      .messages({
        'number.base': 'Stok harus berupa angka',
        'number.integer': 'Stok harus bilangan bulat',
        'number.min': 'Stok tidak boleh negatif'
      }),

    category: Joi.string()
      .valid(...this.categories)
      .required()
      .messages({
        'any.only': `Kategori harus salah satu: ${this.categories.join(', ')}`,
        'string.empty': 'Kategori wajib diisi'
      }),

    images: Joi.array()
      .items(
        Joi.object({
          url: this.patterns.url.required(),
          alt: Joi.string().max(100).optional(),
          isPrimary: Joi.boolean().default(false)
        })
      )
      .max(10)
      .default([])
      .messages({
        'array.max': 'Maksimal 10 gambar per produk'
      }),

    variants: Joi.array()
      .items(
        Joi.object({
          name: Joi.string().required(),
          sku: Joi.string().optional(),
          price: Joi.number().positive().optional(),
          stock: Joi.number().integer().min(0).default(0)
        })
      )
      .optional(),

    tags: Joi.array()
      .items(Joi.string().max(50))
      .max(20)
      .optional(),

    weight: Joi.number()
      .positive()
      .unit('gram')
      .optional(),

    dimensions: Joi.object({
      length: Joi.number().positive().optional(),
      width: Joi.number().positive().optional(),
      height: Joi.number().positive().optional(),
      unit: Joi.string().valid('cm', 'inch').default('cm')
    }).optional(),

    isActive: Joi.boolean().default(true)
  });

  /**
   * Update product validation schema
   */
  update = Joi.object({
    name: Joi.string().min(3).max(200).optional(),
    description: Joi.string().min(10).max(5000).optional(),
    price: Joi.number().positive().precision(2).optional(),
    stock: Joi.number().integer().min(0).optional(),
    category: Joi.string().valid(...this.categories).optional(),
    images: Joi.array().items(
      Joi.object({
        url: this.patterns.url,
        alt: Joi.string().max(100),
        isPrimary: Joi.boolean()
      })
    ).max(10).optional(),
    variants: Joi.array().items(
      Joi.object({
        name: Joi.string(),
        sku: Joi.string(),
        price: Joi.number().positive(),
        stock: Joi.number().integer().min(0)
      })
    ).optional(),
    tags: Joi.array().items(Joi.string().max(50)).max(20).optional(),
    weight: Joi.number().positive().optional(),
    dimensions: Joi.object({
      length: Joi.number().positive(),
      width: Joi.number().positive(),
      height: Joi.number().positive(),
      unit: Joi.string().valid('cm', 'inch')
    }).optional(),
    isActive: Joi.boolean().optional()
  }).min(1).messages({
    'object.min': 'Minimal satu field harus diupdate'
  });

  /**
   * Product ID param validator
   */
  productIdParam = Joi.object({
    id: this.patterns.id.required()
  });

  /**
   * Query products validator
   */
  queryProducts = Joi.object({
    ...this.pagination,
    category: Joi.string()
      .valid(...this.categories)
      .optional(),
    minPrice: Joi.number().positive().optional(),
    maxPrice: Joi.number().positive().min(Joi.ref('minPrice')).optional(),
    inStock: Joi.boolean().optional(),
    search: Joi.string().trim().max(100).optional(),
    tags: Joi.array().items(Joi.string()).single().optional()
  });
}

module.exports = new ProductValidator();

D. Validation Middleware

src/middleware/validation.middleware.js

javascript
/**
 * VALIDATION MIDDLEWARE
 * Menangani validasi request secara terpusat
 */

const { ValidationError } = require('../errors/AppError');
const logger = require('../utils/logger');

class ValidationMiddleware {
  /**
   * Validate request body against schema
   */
  validateBody(schema, options = {}) {
    return async (req, res, next) => {
      try {
        const { value, error } = await schema.validateAsync(req.body, {
          abortEarly: false,
          stripUnknown: true,
          ...options
        });

        if (error) {
          throw this.formatJoiError(error);
        }

        // Replace body with validated and sanitized data
        req.body = value;
        next();
      } catch (error) {
        next(error);
      }
    };
  }

  /**
   * Validate request query against schema
   */
  validateQuery(schema, options = {}) {
    return async (req, res, next) => {
      try {
        const { value, error } = await schema.validateAsync(req.query, {
          abortEarly: false,
          stripUnknown: true,
          convert: true,
          ...options
        });

        if (error) {
          throw this.formatJoiError(error);
        }

        req.query = value;
        next();
      } catch (error) {
        next(error);
      }
    };
  }

  /**
   * Validate request params against schema
   */
  validateParams(schema, options = {}) {
    return async (req, res, next) => {
      try {
        const { value, error } = await schema.validateAsync(req.params, {
          abortEarly: false,
          stripUnknown: true,
          ...options
        });

        if (error) {
          throw this.formatJoiError(error);
        }

        req.params = value;
        next();
      } catch (error) {
        next(error);
      }
    };
  }

  /**
   * Format Joi error to ValidationError
   */
  formatJoiError(error) {
    const details = error.details.map(detail => ({
      field: detail.path.join('.'),
      message: detail.message.replace(/"/g, ''),
      type: detail.type,
      context: detail.context
    }));

    return new ValidationError('Validasi data gagal', details);
  }

  /**
   * Custom validation function
   */
  validate(validator, property = 'body') {
    return async (req, res, next) => {
      try {
        const data = req[property];
        const result = await validator(data);

        if (result === true) {
          next();
        } else {
          const error = new ValidationError(
            'Validasi gagal',
            Array.isArray(result) ? result : [{ message: result }]
          );
          next(error);
        }
      } catch (error) {
        next(error);
      }
    };
  }

  /**
   * Conditional validation
   * Only validate if condition is met
   */
  validateIf(condition, schema, property = 'body') {
    return async (req, res, next) => {
      const shouldValidate = typeof condition === 'function'
        ? condition(req)
        : condition;

      if (shouldValidate) {
        const validator = this.validateBody(schema);
        return validator(req, res, next);
      }

      next();
    };
  }

  /**
   * Validate file upload
   */
  validateFile(options = {}) {
    const {
      maxSize = 5 * 1024 * 1024, // 5MB
      allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
      maxFiles = 5,
      required = false
    } = options;

    return (req, res, next) => {
      const files = req.files || (req.file ? [req.file] : []);
      const errors = [];

      // Check if file is required but not provided
      if (required && files.length === 0) {
        errors.push({
          field: 'file',
          message: 'File wajib diupload',
          type: 'required'
        });
      }

      // Validate each file
      files.forEach((file, index) => {
        // Check file size
        if (file.size > maxSize) {
          errors.push({
            field: file.fieldname || 'file',
            index,
            message: `Ukuran file terlalu besar. Maksimal ${maxSize / 1024 / 1024}MB`,
            type: 'file_size',
            max: maxSize,
            current: file.size
          });
        }

        // Check file type
        if (!allowedTypes.includes(file.mimetype)) {
          errors.push({
            field: file.fieldname || 'file',
            index,
            message: `Tipe file tidak didukung. Format yang diizinkan: ${allowedTypes.join(', ')}`,
            type: 'file_type',
            allowed: allowedTypes,
            current: file.mimetype
          });
        }
      });

      // Check max files
      if (files.length > maxFiles) {
        errors.push({
          field: 'files',
          message: `Maksimal ${maxFiles} file dapat diupload`,
          type: 'max_files',
          max: maxFiles,
          current: files.length
        });
      }

      if (errors.length > 0) {
        const error = new ValidationError('Validasi file gagal', errors);
        return next(error);
      }

      next();
    };
  }
}

module.exports = new ValidationMiddleware();

E. Advanced Error Tracking

src/utils/errorTracker.js

javascript
/**
 * ERROR TRACKING & ANALYTICS
 * Melacak error patterns untuk improvement
 */

const fs = require('fs').promises;
const path = require('path');
const logger = require('./logger');

class ErrorTracker {
  constructor() {
    this.stats = {
      totalErrors: 0,
      errorsByType: {},
      errorsByEndpoint: {},
      errorsByStatusCode: {},
      errorsByHour: Array(24).fill(0),
      topErrors: [],
      recentErrors: []
    };

    this.alertThresholds = {
      errorRate: 100, // errors per minute
      serverErrors: 10, // 5xx errors per minute
      criticalErrors: 5 // critical errors per minute
    };

    this.startTime = Date.now();
    this.errorCounts = {
      lastMinute: [],
      lastHour: []
    };
  }

  /**
   * Track error
   */
  trackError(error, req) {
    const timestamp = new Date();
    const hour = timestamp.getHours();
    const errorType = error.name || 'UnknownError';
    const statusCode = error.statusCode || 500;
    const endpoint = req?.originalUrl || 'unknown';
    const method = req?.method || 'unknown';

    // Update stats
    this.stats.totalErrors++;
    this.stats.errorsByType[errorType] = (this.stats.errorsByType[errorType] || 0) + 1;
    this.stats.errorsByStatusCode[statusCode] = (this.stats.errorsByStatusCode[statusCode] || 0) + 1;
    this.stats.errorsByHour[hour]++;

    const endpointKey = `${method}:${endpoint}`;
    this.stats.errorsByEndpoint[endpointKey] = (this.stats.errorsByEndpoint[endpointKey] || 0) + 1;

    // Track recent errors
    const errorRecord = {
      timestamp: timestamp.toISOString(),
      type: errorType,
      code: error.errorCode || error.code,
      message: error.message,
      statusCode,
      endpoint,
      method,
      userId: req?.user?.id,
      requestId: req?.requestId
    };

    this.stats.recentErrors.unshift(errorRecord);
    if (this.stats.recentErrors.length > 100) {
      this.stats.recentErrors.pop();
    }

    // Update top errors
    this.updateTopErrors(errorType, error.message);

    // Check thresholds and alert
    this.checkThresholds(error, timestamp);
  }

  /**
   * Update top errors
   */
  updateTopErrors(errorType, errorMessage) {
    const key = `${errorType}: ${errorMessage.substring(0, 50)}`;
    const existingError = this.stats.topErrors.find(e => e.key === key);

    if (existingError) {
      existingError.count++;
    } else {
      this.stats.topErrors.push({
        key,
        type: errorType,
        message: errorMessage,
        count: 1
      });
    }

    // Sort by count descending
    this.stats.topErrors.sort((a, b) => b.count - a.count);
    
    // Keep top 20
    if (this.stats.topErrors.length > 20) {
      this.stats.topErrors.pop();
    }
  }

  /**
   * Check error thresholds
   */
  checkThresholds(error, timestamp) {
    const now = timestamp.getTime();
    const oneMinuteAgo = now - 60000;
    const oneHourAgo = now - 3600000;

    // Update error counts
    this.errorCounts.lastMinute = this.errorCounts.lastMinute.filter(
      t => t > oneMinuteAgo
    );
    this.errorCounts.lastMinute.push(now);

    this.errorCounts.lastHour = this.errorCounts.lastHour.filter(
      t => t > oneHourAgo
    );
    this.errorCounts.lastHour.push(now);

    const errorsPerMinute = this.errorCounts.lastMinute.length;
    const serverErrorsPerMinute = this.errorCounts.lastMinute.filter(
      (_, i) => this.stats.recentErrors[i]?.statusCode >= 500
    ).length;

    // Check thresholds
    if (errorsPerMinute > this.alertThresholds.errorRate) {
      this.sendAlert('HIGH_ERROR_RATE', {
        rate: errorsPerMinute,
        threshold: this.alertThresholds.errorRate,
        timestamp: timestamp.toISOString()
      });
    }

    if (serverErrorsPerMinute > this.alertThresholds.serverErrors) {
      this.sendAlert('HIGH_SERVER_ERROR_RATE', {
        rate: serverErrorsPerMinute,
        threshold: this.alertThresholds.serverErrors,
        timestamp: timestamp.toISOString()
      });
    }

    if (error.critical && this.errorCounts.lastMinute.filter(
      (_, i) => this.stats.recentErrors[i]?.critical
    ).length > this.alertThresholds.criticalErrors) {
      this.sendAlert('CRITICAL_ERRORS', {
        error: error.message,
        timestamp: timestamp.toISOString()
      });
    }
  }

  /**
   * Send alert (implement with email/Slack/PagerDuty)
   */
  sendAlert(type, data) {
    logger.error(`🚨 ALERT: ${type}`, data);
    
    // Implement actual alerting here
    // - Send email
    // - Send Slack message
    // - Call PagerDuty API
    // - Send to monitoring service (Datadog, NewRelic)
  }

  /**
   * Get error statistics
   */
  getStats(timeRange = 'all') {
    const stats = { ...this.stats };
    
    // Add uptime
    stats.uptime = Date.now() - this.startTime;
    stats.uptimeFormatted = this.formatUptime(stats.uptime);
    
    // Add error rate
    stats.errorsPerMinute = this.errorCounts.lastMinute.length;
    stats.errorsPerHour = this.errorCounts.lastHour.length;
    
    // Add health score (0-100)
    stats.healthScore = this.calculateHealthScore();
    
    return stats;
  }

  /**
   * Calculate health score
   */
  calculateHealthScore() {
    const baseScore = 100;
    
    // Deduct points for server errors
    const serverErrorCount = Object.entries(this.stats.errorsByStatusCode)
      .filter(([code]) => parseInt(code) >= 500)
      .reduce((sum, [, count]) => sum + count, 0);
    
    // Deduct points for recent errors
    const recentErrorCount = this.errorCounts.lastHour.length;
    
    // Deduct points for unique error types
    const errorTypeCount = Object.keys(this.stats.errorsByType).length;
    
    let score = baseScore;
    score -= serverErrorCount * 0.1;
    score -= recentErrorCount * 0.05;
    score -= errorTypeCount * 0.2;
    
    return Math.max(0, Math.min(100, Math.round(score)));
  }

  /**
   * Format uptime
   */
  formatUptime(ms) {
    const seconds = Math.floor(ms / 1000);
    const minutes = Math.floor(seconds / 60);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);

    return {
      days,
      hours: hours % 24,
      minutes: minutes % 60,
      seconds: seconds % 60,
      milliseconds: ms % 1000
    };
  }

  /**
   * Reset stats
   */
  resetStats() {
    this.stats = {
      totalErrors: 0,
      errorsByType: {},
      errorsByEndpoint: {},
      errorsByStatusCode: {},
      errorsByHour: Array(24).fill(0),
      topErrors: [],
      recentErrors: []
    };
    
    this.errorCounts = {
      lastMinute: [],
      lastHour: []
    };
  }

  /**
   * Save stats to file
   */
  async saveStatsToFile(filePath = './logs/error-stats.json') {
    try {
      const dir = path.dirname(filePath);
      await fs.mkdir(dir, { recursive: true });
      
      const statsWithTimestamp = {
        ...this.getStats(),
        savedAt: new Date().toISOString()
      };
      
      await fs.writeFile(
        filePath,
        JSON.stringify(statsWithTimestamp, null, 2)
      );
      
      logger.info(`Error stats saved to ${filePath}`);
    } catch (error) {
      logger.error('Failed to save error stats:', error);
    }
  }
}

module.exports = new ErrorTracker();

F. Complete Error Handling Setup

src/app.js

javascript
/**
 * EXPRESS APP WITH COMPLETE ERROR HANDLING
 */

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const { v4: uuidv4 } = require('uuid');

const config = require('./config/env');
const logger = require('./utils/logger');
const errorTracker = require('./utils/errorTracker');
const errorHandler = require('./middleware/errorHandler.middleware');
const validationMiddleware = require('./middleware/validation.middleware');

// Import validators
const userValidator = require('./validators/UserValidator');
const productValidator = require('./validators/ProductValidator');

// Import routes
const authRoutes = require('./routes/auth.routes');
const userRoutes = require('./routes/user.routes');
const productRoutes = require('./routes/product.routes');

const app = express();

// ============= GLOBAL MIDDLEWARE =============

// Request ID
app.use((req, res, next) => {
  req.requestId = req.headers['x-request-id'] || uuidv4();
  res.setHeader('X-Request-ID', req.requestId);
  next();
});

// Security & parsing
app.use(helmet());
app.use(cors(config.CORS_OPTIONS));
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// Request logging
app.use((req, res, next) => {
  logger.info(`${req.method} ${req.originalUrl}`, {
    requestId: req.requestId,
    ip: req.ip,
    userAgent: req.headers['user-agent']
  });
  next();
});

// ============= API ROUTES =============

// Health check
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    requestId: req.requestId
  });
});

// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);

// ============= EXAMPLE: CONTROLLER WITH ERROR HANDLING =============

const { asyncHandler } = require('./middleware/errorHandler.middleware');
const { ValidationError, NotFoundError } = require('./errors/AppError');

// Contoh controller dengan proper error handling
app.post('/api/examples/validation', 
  validationMiddleware.validateBody(userValidator.register),
  asyncHandler(async (req, res) => {
    // Business logic here
    const { email, password, name } = req.body;
    
    // Simulasi duplicate check
    const existingUser = await checkUserExists(email);
    if (existingUser) {
      throw new ValidationError('Email sudah terdaftar', [
        { field: 'email', message: 'Email sudah digunakan' }
      ]);
    }
    
    res.status(201).json({
      success: true,
      message: 'User created successfully',
      data: { email, name }
    });
  })
);

app.get('/api/examples/:id',
  validationMiddleware.validateParams(
    Joi.object({ id: Joi.number().required() })
  ),
  asyncHandler(async (req, res) => {
    const { id } = req.params;
    
    // Simulasi find by id
    const user = await findUserById(id);
    
    if (!user) {
      throw new NotFoundError('User', id);
    }
    
    res.json({
      success: true,
      data: user
    });
  })
);

// ============= ERROR HANDLING PIPELINE =============

/**
 * ORDER OF ERROR HANDLERS IS CRITICAL:
 * 1. Celebrate/Joi error handlers (convert to AppError)
 * 2. Database error handlers
 * 3. HTTP error handlers
 * 4. Multer error handlers
 * 5. 404 handler (if no route matches)
 * 6. Main error handler (final)
 */

// 1. Joi validation error handler
app.use(errorHandler.joiErrorHandler());

// 2. Celebrate validation error handler
app.use(errorHandler.celebrateErrorHandler());

// 3. Database error handler
app.use(errorHandler.databaseErrorHandler());

// 4. HTTP error handler
app.use(errorHandler.httpErrorHandler());

// 5. Multer error handler
app.use(errorHandler.multerErrorHandler());

// 6. 404 handler - must be after all routes
app.use(errorHandler.notFoundHandler());

// 7. Main error handler - must be LAST
app.use(errorHandler.mainErrorHandler());

// ============= UNCAUGHT EXCEPTIONS =============

// Handle unhandled rejections
errorHandler.unhandledRejectionHandler();

// Handle uncaught exceptions
errorHandler.uncaughtExceptionHandler();

// ============= GRACEFUL SHUTDOWN =============

process.on('SIGTERM', () => {
  logger.info('SIGTERM received. Starting graceful shutdown...');
  
  // Save error stats before exit
  errorTracker.saveStatsToFile()
    .finally(() => {
      process.exit(0);
    });
});

process.on('SIGINT', () => {
  logger.info('SIGINT received. Starting graceful shutdown...');
  
  // Save error stats before exit
  errorTracker.saveStatsToFile()
    .finally(() => {
      process.exit(0);
    });
});

module.exports = app;

3.3 Validation Patterns & Techniques

javascript
/**
 * VALIDATION PATTERNS & BEST PRACTICES
 */

// ============= 1. LAYERED VALIDATION =============

const layeredValidation = {
  /**
   * Layer 1: Client-side validation (UX)
   * - Instant feedback
   * - Reduce server load
   * - Not security boundary!
   */
  clientSide: `
    // React/Vue example
    if (email.length === 0) {
      setError('Email is required');
      return;
    }
  `,

  /**
   * Layer 2: API Gateway validation (if exists)
   * - Rate limiting
   * - Basic format checks
   * - Request size limits
   */
  gateway: `
    # Nginx/AWS API Gateway
    client_max_body_size 10M;
    limit_req zone=auth burst=5;
  `,

  /**
   * Layer 3: Express middleware validation
   * - Schema validation
   * - Sanitization
   * - Authentication/Authorization
   */
  middleware: `
    app.post('/users',
      validateBody(userSchema),
      authenticate,
      userController.create
    );
  `,

  /**
   * Layer 4: Service layer validation
   * - Business rules
   * - Complex logic
   * - Cross-field validation
   */
  service: `
    async createUser(data) {
      // Business rule: Email domain must be allowed
      if (!allowedDomains.includes(data.email.split('@')[1])) {
        throw new BusinessError('Email domain not allowed');
      }
    }
  `,

  /**
   * Layer 5: Database validation
   * - Unique constraints
   * - Foreign key constraints
   * - Not null constraints
   */
  database: `
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      email VARCHAR(255) UNIQUE NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    );
  `
};

// ============= 2. SANITIZATION TECHNIQUES =============

const sanitization = {
  /**
   * XSS Prevention
   */
  xssPrevention: (input) => {
    if (typeof input !== 'string') return input;
    
    return input
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;')
      .replace(/\//g, '&#x2F;');
  },

  /**
   * SQL Injection Prevention
   */
  sqlInjectionPrevention: (input) => {
    if (typeof input !== 'string') return input;
    
    // Remove SQL keywords (basic)
    const sqlKeywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'UNION', '--'];
    let sanitized = input;
    
    sqlKeywords.forEach(keyword => {
      const regex = new RegExp(keyword, 'gi');
      sanitized = sanitized.replace(regex, '');
    });
    
    return sanitized;
  },

  /**
   * Trim and normalize
   */
  normalize: (input) => {
    if (typeof input === 'string') {
      return input
        .trim()
        .replace(/\s+/g, ' ')
        .normalize('NFKC');
    }
    return input;
  },

  /**
   * Email normalization
   */
  normalizeEmail: (email) => {
    if (!email || typeof email !== 'string') return email;
    
    return email
      .trim()
      .toLowerCase()
      .replace(/\s+/g, '');
  }
};

// ============= 3. CUSTOM VALIDATORS =============

const customValidators = {
  /**
   * Indonesian phone number
   */
  indonesianPhone: (value) => {
    const regex = /^(\+62|62|0)[2-9][0-9]{7,11}$/;
    if (!regex.test(value)) {
      throw new Error('Nomor HP Indonesia tidak valid');
    }
    return value;
  },

  /**
   * NIK (Indonesian ID Card)
   */
  nik: (value) => {
    const regex = /^[0-9]{16}$/;
    if (!regex.test(value)) {
      throw new Error('NIK harus 16 digit angka');
    }
    return value;
  },

  /**
   * Postal code (Indonesia)
   */
  postalCode: (value) => {
    const regex = /^[0-9]{5}$/;
    if (!regex.test(value)) {
      throw new Error('Kode pos harus 5 digit');
    }
    return value;
  },

  /**
   * URL with custom scheme
   */
  customUrl: (value, allowedSchemes = ['http', 'https']) => {
    try {
      const url = new URL(value);
      if (!allowedSchemes.includes(url.protocol.slice(0, -1))) {
        throw new Error(`Protocol harus ${allowedSchemes.join(' atau ')}`);
      }
      return value;
    } catch {
      throw new Error('URL tidak valid');
    }
  },

  /**
   * Age validator (13+ years)
   */
  minimumAge: (birthDate, minAge = 13) => {
    const today = new Date();
    const birth = new Date(birthDate);
    let age = today.getFullYear() - birth.getFullYear();
    const monthDiff = today.getMonth() - birth.getMonth();
    
    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
      age--;
    }
    
    if (age < minAge) {
      throw new Error(`Minimal umur ${minAge} tahun`);
    }
    
    return age;
  }
};

3.4 Error Response Standards

javascript
/**
 * ERROR RESPONSE STANDARDS
 */

// ============= 1. RFC 7807 PROBLEM DETAILS =============

const rfc7807Format = {
  /**
   * RFC 7807: Problem Details for HTTP APIs
   * https://tools.ietf.org/html/rfc7807
   */
  problemDetails: (err, req) => ({
    type: `https://api.example.com/errors/${err.errorCode || 'general'}`,
    title: err.name || 'Error',
    status: err.statusCode || 500,
    detail: err.message,
    instance: req.originalUrl,
    timestamp: new Date().toISOString(),
    requestId: req.requestId
  }),

  /**
   * Validation Problem Details
   */
  validationProblem: (err, req) => ({
    type: 'https://api.example.com/errors/validation',
    title: 'Validation Error',
    status: 400,
    detail: 'Request validation failed',
    instance: req.originalUrl,
    errors: err.details,
    timestamp: new Date().toISOString()
  })
};

// ============= 2. JSON:API ERROR FORMAT =============

const jsonApiFormat = {
  /**
   * JSON:API Error Format
   * https://jsonapi.org/format/#errors
   */
  jsonApiError: (err, req) => ({
    jsonapi: { version: '1.0' },
    errors: [{
      id: req.requestId,
      code: err.errorCode,
      status: err.statusCode?.toString(),
      title: err.name,
      detail: err.message,
      source: {
        pointer: req.path,
        parameter: err.field
      },
      meta: {
        timestamp: new Date().toISOString(),
        environment: process.env.NODE_ENV
      }
    }]
  })
};

// ============= 3. GRAPHQL ERROR FORMAT =============

const graphqlFormat = {
  /**
   * GraphQL Error Format
   */
  graphqlError: (err) => ({
    errors: [{
      message: err.message,
      extensions: {
        code: err.errorCode,
        statusCode: err.statusCode,
        timestamp: new Date().toISOString()
      },
      locations: err.locations,
      path: err.path
    }]
  })
};

// ============= 4. CUSTOM ENTERPRISE FORMAT =============

const enterpriseFormat = {
  /**
   * Enterprise error format with correlation
   */
  enterpriseError: (err, req) => ({
    success: false,
    error: {
      correlationId: req.requestId,
      timestamp: new Date().toISOString(),
      service: process.env.SERVICE_NAME || 'api-service',
      version: process.env.APP_VERSION || '1.0.0',
      code: err.errorCode || 'UNKNOWN_ERROR',
      message: err.message,
      details: err.details,
      path: req.originalUrl,
      method: req.method
    },
    meta: {
      environment: process.env.NODE_ENV,
      api: {
        version: req.headers['accept-version'] || 'v1',
        format: 'json'
      }
    }
  })
};

3.5 Testing Error Handling & Validation

tests/error-handling/validation.test.js

javascript
/**
 * TESTING VALIDATION & ERROR HANDLING
 */

const request = require('supertest');
const app = require('../../src/app');
const { ValidationError } = require('../../src/errors/AppError');

describe('Validation System', () => {
  describe('User Registration Validation', () => {
    it('should reject invalid email format', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'not-an-email',
          password: 'Test123!',
          name: 'Test User'
        });

      expect(response.statusCode).toBe(400);
      expect(response.body.error.code).toBe('VALIDATION_ERROR');
      expect(response.body.error.validation).toBeDefined();
      expect(response.body.error.validation[0].field).toBe('email');
    });

    it('should reject weak password', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'test@example.com',
          password: 'weak',
          name: 'Test User'
        });

      expect(response.statusCode).toBe(400);
      expect(response.body.error.validation[0].field).toBe('password');
    });

    it('should reject missing required fields', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'test@example.com'
        });

      expect(response.statusCode).toBe(400);
      expect(response.body.error.validation.length).toBeGreaterThan(1);
    });
  });

  describe('Product Validation', () => {
    it('should reject negative price', async () => {
      const response = await request(app)
        .post('/api/products')
        .set('Authorization', 'Bearer valid-token')
        .send({
          name: 'Test Product',
          description: 'This is a test product',
          price: -1000,
          category: 'electronics'
        });

      expect(response.statusCode).toBe(400);
      expect(response.body.error.validation[0].field).toBe('price');
    });

    it('should reject invalid category', async () => {
      const response = await request(app)
        .post('/api/products')
        .set('Authorization', 'Bearer valid-token')
        .send({
          name: 'Test Product',
          description: 'This is a test product',
          price: 100000,
          category: 'invalid-category'
        });

      expect(response.statusCode).toBe(400);
      expect(response.body.error.validation[0].field).toBe('category');
    });
  });
});

describe('Error Handling System', () => {
  describe('404 Handler', () => {
    it('should return 404 for non-existent route', async () => {
      const response = await request(app)
        .get('/api/non-existent-route');

      expect(response.statusCode).toBe(404);
      expect(response.body.error.code).toBe('NOT_FOUND');
    });
  });

  describe('Authentication Errors', () => {
    it('should return 401 for missing token', async () => {
      const response = await request(app)
        .get('/api/auth/me');

      expect(response.statusCode).toBe(401);
      expect(response.body.error.code).toBe('NO_TOKEN');
    });

    it('should return 401 for invalid token', async () => {
      const response = await request(app)
        .get('/api/auth/me')
        .set('Authorization', 'Bearer invalid.token.here');

      expect(response.statusCode).toBe(401);
      expect(response.body.error.code).toBe('INVALID_TOKEN');
    });
  });

  describe('Database Errors', () => {
    it('should handle duplicate key error', async () => {
      // Register first user
      await request(app)
        .post('/api/auth/register')
        .send({
          email: 'duplicate@test.com',
          password: 'Test123!',
          name: 'Test User'
        });

      // Try to register same email
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'duplicate@test.com',
          password: 'Test123!',
          name: 'Another User'
        });

      expect(response.statusCode).toBe(409);
      expect(response.body.error.code).toBe('VALIDATION_ERROR');
      expect(response.body.error.validation[0].field).toBe('email');
    });
  });

  describe('Error Response Format', () => {
    it('should have consistent error structure', async () => {
      const response = await request(app)
        .get('/api/non-existent');

      expect(response.body).toHaveProperty('success', false);
      expect(response.body.error).toHaveProperty('code');
      expect(response.body.error).toHaveProperty('message');
      expect(response.body.error).toHaveProperty('timestamp');
      expect(response.body.error).toHaveProperty('path');
      expect(response.body.error).toHaveProperty('method');
      expect(response.body.error).toHaveProperty('requestId');
    });

    it('should not expose stack trace in production', async () => {
      const originalEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const response = await request(app)
        .get('/api/non-existent');

      expect(response.body.error).not.toHaveProperty('stack');
      
      process.env.NODE_ENV = originalEnv;
    });
  });
});

3.6 Error Monitoring Dashboard API

src/controllers/monitoring.controller.js

javascript
/**
 * MONITORING CONTROLLER
 * API untuk melihat error statistics
 */

const errorTracker = require('../utils/errorTracker');
const { asyncHandler } = require('../middleware/errorHandler.middleware');
const { ForbiddenError } = require('../errors/AppError');

class MonitoringController {
  /**
   * GET /api/monitoring/errors/stats
   * Get error statistics (admin only)
   */
  getErrorStats = asyncHandler(async (req, res) => {
    // Only admin can access
    if (req.user?.role !== 'admin') {
      throw new ForbiddenError('Admin access required');
    }

    const { timeRange = 'all' } = req.query;
    const stats = errorTracker.getStats(timeRange);

    res.json({
      success: true,
      data: stats,
      timestamp: new Date().toISOString()
    });
  });

  /**
   * GET /api/monitoring/errors/recent
   * Get recent errors
   */
  getRecentErrors = asyncHandler(async (req, res) => {
    if (req.user?.role !== 'admin') {
      throw new ForbiddenError('Admin access required');
    }

    const { limit = 50 } = req.query;
    const recentErrors = errorTracker.stats.recentErrors.slice(0, limit);

    res.json({
      success: true,
      data: recentErrors,
      total: errorTracker.stats.totalErrors,
      timestamp: new Date().toISOString()
    });
  });

  /**
   * GET /api/monitoring/errors/top
   * Get top errors
   */
  getTopErrors = asyncHandler(async (req, res) => {
    if (req.user?.role !== 'admin') {
      throw new ForbiddenError('Admin access required');
    }

    const { limit = 20 } = req.query;
    const topErrors = errorTracker.stats.topErrors.slice(0, limit);

    res.json({
      success: true,
      data: topErrors,
      timestamp: new Date().toISOString()
    });
  });

  /**
   * POST /api/monitoring/errors/reset
   * Reset error statistics
   */
  resetStats = asyncHandler(async (req, res) => {
    if (req.user?.role !== 'admin') {
      throw new ForbiddenError('Admin access required');
    }

    errorTracker.resetStats();

    res.json({
      success: true,
      message: 'Error statistics reset successfully',
      timestamp: new Date().toISOString()
    });
  });

  /**
   * GET /api/monitoring/health
   * Detailed health check
   */
  healthCheck = asyncHandler(async (req, res) => {
    const health = {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      memory: process.memoryUsage(),
      cpu: process.cpuUsage(),
      version: process.version,
      platform: process.platform,
      errorStats: errorTracker.getStats('hour')
    };

    res.json({
      success: true,
      data: health
    });
  });
}

module.exports = new MonitoringController();

3.7 Best Practices Summary

javascript
/**
 * ERROR HANDLING & VALIDATION BEST PRACTICES
 */

const bestPractices = {
  /**
   * 1. THROW EARLY, CATCH LATE
   * Validasi sedini mungkin, tangani error di satu tempat
   */
  throwEarly: `
    // ✅ GOOD: Validasi di middleware
    app.post('/users', 
      validateBody(userSchema), // Throw early
      controller.createUser    // Catch late in global handler
    );
    
    // ❌ BAD: Validasi di controller
    app.post('/users', (req, res) => {
      if (!req.body.email) { // Manual validation in controller
        return res.status(400).json({...});
      }
    });
  `,

  /**
   * 2. USE STANDARDIZED ERROR FORMAT
   * Konsistensi format error
   */
  standardFormat: `
    // ✅ GOOD: Consistent format
    {
      "success": false,
      "error": {
        "code": "VALIDATION_ERROR",
        "message": "...",
        "details": [...],
        "timestamp": "..."
      }
    }
    
    // ❌ BAD: Different format per endpoint
    { "err": true, "msg": "..." }
    { "error": "..." }
    { "status": "fail", "data": {...} }
  `,

  /**
   * 3. DISTINGUISH OPERATIONAL VS PROGRAMMING ERRORS
   * Operational: Expected errors (validation, 404)
   * Programming: Bugs in code (undefined, null reference)
   */
  errorTypes: `
    // ✅ Operational Error - Expected
    throw new ValidationError('Invalid input');
    
    // ❌ Programming Error - Unexpected
    user.profile.name // TypeError if user is null
    
    // Handle appropriately
    if (err.isOperational) {
      // Send to client
    } else {
      // Restart process, alert developer
    }
  `,

  /**
   * 4. DON'T EXPOSE INTERNAL DETAILS
   * Hide stack traces, internal paths
   */
  hideInternals: `
    // ✅ GOOD: Development
    if (process.env.NODE_ENV === 'development') {
      response.error.stack = err.stack;
    }
    
    // ✅ GOOD: Production
    response.error.message = 'Internal server error';
  `,

  /**
   * 5. VALIDATE EVERY INPUT
   * Never trust client data
   */
  validateAllInputs: `
    // ✅ GOOD: Validate everything
    validateBody(schema)      // POST/PUT body
    validateQuery(schema)     // Query parameters
    validateParams(schema)    // URL parameters
    validateFile(options)     // File uploads
    
    // Also validate at service layer
    // Validate business rules
    // Validate database constraints
  `,

  /**
   * 6. USE ASYNC/AWAIT WITH TRY-CATCH
   * Or use asyncHandler wrapper
   */
  asyncErrorHandling: `
    // ✅ GOOD: asyncHandler wrapper
    app.get('/users', asyncHandler(async (req, res) => {
      const users = await User.find();
      res.json(users);
    }));
    
    // ✅ GOOD: try-catch
    app.get('/users', async (req, res, next) => {
      try {
        const users = await User.find();
        res.json(users);
      } catch (error) {
        next(error);
      }
    });
  `,

  /**
   * 7. LOG COMPREHENSIVELY
   * Include context for debugging
   */
  comprehensiveLogging: `
    // ✅ GOOD: Include context
    logger.error('Error', {
      requestId: req.id,
      userId: req.user?.id,
      path: req.path,
      method: req.method,
      error: err.message,
      stack: err.stack
    });
  `,

  /**
   * 8. GRACEFUL DEGRADATION
   * Fail gracefully, don't crash
   */
  gracefulDegradation: `
    // ✅ GOOD: Fallback
    try {
      await sendEmail();
    } catch (error) {
      logger.error('Email failed', error);
      // Queue for retry, don't fail the request
      emailQueue.add({ to, subject });
    }
  `,

  /**
   * 9. USE HTTP STATUS CODES CORRECTLY
   * 4xx: Client errors
   * 5xx: Server errors
   */
  statusCodes: {
    400: 'Bad Request - Invalid input',
    401: 'Unauthorized - Authentication required',
    403: 'Forbidden - Insufficient permissions',
    404: 'Not Found - Resource does not exist',
    409: 'Conflict - Duplicate/State conflict',
    422: 'Unprocessable Entity - Business rule violation',
    429: 'Too Many Requests - Rate limit',
    500: 'Internal Server Error - Unexpected',
    503: 'Service Unavailable - Database down'
  },

  /**
   * 10. DOCUMENT ERROR CODES
   * Maintain error code catalog
   */
  errorCatalog: {
    VALIDATION_ERROR: {
      description: 'Request validation failed',
      action: 'Check request body/query/params'
    },
    AUTHENTICATION_ERROR: {
      description: 'User not authenticated',
      action: 'Provide valid token'
    },
    AUTHORIZATION_ERROR: {
      description: 'Insufficient permissions',
      action: 'Request higher privileges'
    }
  }
};

3.8 Latihan Praktikum

Exercise 1: Custom Validation Rules

javascript
/**
 * TODO: Implement custom validators untuk:
 * 1. Indonesian Tax ID (NPWP)
 * 2. Vehicle Registration Plate
 * 3. Bank Account Number
 * 4. Credit Card Number (Luhn algorithm)
 */

class IndonesianValidator {
  static npwp(npwp) {
    // Format: XX.XXX.XXX.X-XXX.XXX
    // Implementasi
  }

  static licensePlate(plate) {
    // Format: B 1234 XYZ
    // Implementasi
  }

  static bankAccount(accountNumber) {
    // Validasi untuk beberapa bank
    // Implementasi
  }
}

Exercise 2: Error Alert System

javascript
/**
 * TODO: Implement error alert system:
 * 1. Email alerts for critical errors
 * 2. Slack webhook for warnings
 * 3. SMS for production outages
 * 4. Rate-limited alerts
 */

class AlertSystem {
  async sendEmailAlert(error, recipients) {
    // Implementasi
  }

  async sendSlackAlert(error, channel) {
    // Implementasi
  }

  async sendSmsAlert(error, phoneNumber) {
    // Implementasi
  }
}

Exercise 3: Request/Response Logging Middleware

javascript
/**
 * TODO: Build comprehensive logging middleware:
 * 1. Log all requests with timing
 * 2. Log all errors with context
 * 3. Rotate log files
 * 4. Searchable log format (JSON)
 * 5. Sensitive data masking
 */

class LoggingMiddleware {
  requestLogger() {
    // Implementasi
  }

  errorLogger() {
    // Implementasi
  }

  maskSensitiveData(data) {
    // Implementasi
  }
}

Exercise 4: API Validation Testing Suite

javascript
/**
 * TODO: Create validation test suite:
 * 1. Test all validation rules
 * 2. Test edge cases
 * 3. Test error messages
 * 4. Test performance
 */

describe('Validation Suite', () => {
  test('should handle maximum field lengths', () => {
    // Implementasi
  });

  test('should handle Unicode characters', () => {
    // Implementasi
  });

  test('should handle SQL injection attempts', () => {
    // Implementasi
  });

  test('should handle XSS attempts', () => {
    // Implementasi
  });
});

4. Daftar Pustaka

  1. W3Schools (n.d). Node.js Error Handling. https://www.w3schools.com
  2. Express.js Documentation (n.d). Error Handling. https://expressjs.com
  3. DevTo (2025). Handling Custom Errors in Node.js with a Custom ApiError Classhttps://dev.to/nurulislamrimon
  4. Toptal (2024). How to Build a Node.js Error-Handling Systemhttps://www.toptal.com
  5. GeeksForGeekss (2025). How to implement validation in Express JS?. https://www.geeksforgeeks.org

Posting Komentar

0 Komentar