Skip to main content
In addition to Zod, you can implement custom validation logic to apply business rules.

Custom Validation Overview

Business Rules

Domain-specific validation Complex condition handling

DB Validation

Duplicate checks Referential integrity

External APIs

External service validation Real-time data verification

Reusability

Shared validation logic Consistent rule application

Basic Patterns

Validation Methods

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    // Run validation
    await this.validateCreateParams(params);

    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(params).returning({ id: "id" });

    return { userId: user.id };
  }

  private async validateCreateParams(params: CreateUserParams): Promise<void> {
    // Email duplicate check
    const rdb = this.getPuri("r");
    const existing = await rdb.table("users").where("email", params.email).first();

    if (existing) {
      throw new Error("Email already exists");
    }

    // Username duplicate check
    const existingUsername = await rdb.table("users").where("username", params.username).first();

    if (existingUsername) {
      throw new Error("Username already taken");
    }

    // Password strength check
    if (!/[A-Z]/.test(params.password)) {
      throw new Error("Password must contain at least one uppercase letter");
    }

    if (!/[0-9]/.test(params.password)) {
      throw new Error("Password must contain at least one number");
    }
  }
}

Validator Class

// validators/user.validator.ts
export class UserValidator {
  private rdb: any;

  constructor(rdb: any) {
    this.rdb = rdb;
  }

  async validateEmail(email: string): Promise<void> {
    // Format validation
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error("Invalid email format");
    }

    // Duplicate validation
    const existing = await this.rdb.table("users").where("email", email).first();

    if (existing) {
      throw new Error("Email already exists");
    }

    // Domain blacklist check
    const domain = email.split("@")[1];
    const blockedDomains = ["tempmail.com", "throwaway.com"];

    if (blockedDomains.includes(domain)) {
      throw new Error("Email domain not allowed");
    }
  }

  async validateUsername(username: string): Promise<void> {
    // Length validation
    if (username.length < 3 || username.length > 20) {
      throw new Error("Username must be 3-20 characters");
    }

    // Format validation
    if (!/^[a-zA-Z0-9_]+$/.test(username)) {
      throw new Error("Username can only contain letters, numbers, and underscores");
    }

    // Reserved words check
    const reserved = ["admin", "root", "system", "api", "test"];
    if (reserved.includes(username.toLowerCase())) {
      throw new Error("Username is reserved");
    }

    // Duplicate validation
    const existing = await this.rdb.table("users").where("username", username).first();

    if (existing) {
      throw new Error("Username already taken");
    }
  }

  validatePassword(password: string): void {
    // Minimum length
    if (password.length < 8) {
      throw new Error("Password must be at least 8 characters");
    }

    // Complexity
    const hasUppercase = /[A-Z]/.test(password);
    const hasLowercase = /[a-z]/.test(password);
    const hasNumber = /[0-9]/.test(password);
    const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);

    const strength = [hasUppercase, hasLowercase, hasNumber, hasSpecial].filter(Boolean).length;

    if (strength < 3) {
      throw new Error(
        "Password must contain at least 3 of: uppercase, lowercase, number, special character",
      );
    }

    // Common password check
    const common = ["password", "123456", "qwerty", "admin"];
    if (common.includes(password.toLowerCase())) {
      throw new Error("Password is too common");
    }
  }
}

// Usage
class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    const rdb = this.getPuri("r");
    const validator = new UserValidator(rdb);

    // Validate
    await validator.validateEmail(params.email);
    await validator.validateUsername(params.username);
    validator.validatePassword(params.password);

    // Create
    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(params).returning({ id: "id" });

    return { userId: user.id };
  }
}

DB Validation

Duplicate Check

class UserModel extends BaseModelClass {
  private async checkDuplicate(field: string, value: any, excludeId?: number): Promise<void> {
    const rdb = this.getPuri("r");

    let query = rdb.table("users").where(field, value);

    if (excludeId) {
      query = query.whereNot("id", excludeId);
    }

    const existing = await query.first();

    if (existing) {
      throw new Error(`${field} already exists`);
    }
  }

  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    await this.checkDuplicate("email", params.email);
    await this.checkDuplicate("username", params.username);

    // ...
  }

  @api({ httpMethod: "PUT" })
  async update(userId: number, params: UpdateUserParams): Promise<void> {
    if (params.email) {
      await this.checkDuplicate("email", params.email, userId);
    }

    if (params.username) {
      await this.checkDuplicate("username", params.username, userId);
    }

    // ...
  }
}

Referential Integrity

class PostModel extends BaseModelClass {
  private async validateReferences(params: CreatePostParams): Promise<void> {
    const rdb = this.getPuri("r");

    // Check author exists
    const user = await rdb.table("users").where("id", params.userId).first();

    if (!user) {
      throw new Error("User not found");
    }

    // Check category exists
    if (params.categoryId) {
      const category = await rdb.table("categories").where("id", params.categoryId).first();

      if (!category) {
        throw new Error("Category not found");
      }
    }

    // Check tags exist
    if (params.tagIds && params.tagIds.length > 0) {
      const tags = await rdb.table("tags").whereIn("id", params.tagIds).select("id");

      if (tags.length !== params.tagIds.length) {
        throw new Error("Some tags not found");
      }
    }
  }

  @api({ httpMethod: "POST" })
  async create(params: CreatePostParams): Promise<{ postId: number }> {
    await this.validateReferences(params);

    // ...
  }
}

State Transition Validation

class OrderModel extends BaseModelClass {
  private readonly STATE_TRANSITIONS: Record<string, string[]> = {
    pending: ["confirmed", "cancelled"],
    confirmed: ["processing", "cancelled"],
    processing: ["shipped", "cancelled"],
    shipped: ["delivered"],
    delivered: [],
    cancelled: [],
  };

  private async validateStateTransition(orderId: number, newState: string): Promise<void> {
    const rdb = this.getPuri("r");

    const order = await rdb.table("orders").where("id", orderId).first();

    if (!order) {
      throw new Error("Order not found");
    }

    const currentState = order.state;
    const allowedTransitions = this.STATE_TRANSITIONS[currentState] || [];

    if (!allowedTransitions.includes(newState)) {
      throw new Error(`Cannot transition from ${currentState} to ${newState}`);
    }
  }

  @api({ httpMethod: "PUT" })
  async updateState(orderId: number, newState: string): Promise<void> {
    await this.validateStateTransition(orderId, newState);

    const wdb = this.getPuri("w");
    await wdb.table("orders").where("id", orderId).update({ state: newState });
  }
}

Business Rule Validation

Date/Time Validation

class EventModel extends BaseModelClass {
  private validateEventDates(startDate: Date, endDate: Date): void {
    const now = new Date();

    // No past dates
    if (startDate < now) {
      throw new Error("Event start date cannot be in the past");
    }

    // Start date before end date
    if (startDate >= endDate) {
      throw new Error("Start date must be before end date");
    }

    // Maximum duration limit (e.g., 30 days)
    const maxDuration = 30 * 24 * 60 * 60 * 1000; // 30 days
    if (endDate.getTime() - startDate.getTime() > maxDuration) {
      throw new Error("Event duration cannot exceed 30 days");
    }

    // Minimum preparation time (e.g., 1 day)
    const minPreparationTime = 24 * 60 * 60 * 1000; // 1 day
    if (startDate.getTime() - now.getTime() < minPreparationTime) {
      throw new Error("Event must be scheduled at least 1 day in advance");
    }
  }

  @api({ httpMethod: "POST" })
  async create(params: CreateEventParams): Promise<{ eventId: number }> {
    this.validateEventDates(new Date(params.startDate), new Date(params.endDate));

    // ...
  }
}

Quantity/Amount Validation

class OrderModel extends BaseModelClass {
  private async validateOrderItems(items: OrderItem[]): Promise<void> {
    if (items.length === 0) {
      throw new Error("Order must contain at least one item");
    }

    if (items.length > 100) {
      throw new Error("Order cannot contain more than 100 items");
    }

    const rdb = this.getPuri("r");

    for (const item of items) {
      // Quantity validation
      if (item.quantity <= 0) {
        throw new Error("Item quantity must be positive");
      }

      if (item.quantity > 1000) {
        throw new Error("Item quantity cannot exceed 1000");
      }

      // Product existence and stock check
      const product = await rdb.table("products").where("id", item.productId).first();

      if (!product) {
        throw new Error(`Product ${item.productId} not found`);
      }

      if (!product.is_available) {
        throw new Error(`Product ${product.name} is not available`);
      }

      if (product.stock < item.quantity) {
        throw new Error(`Insufficient stock for ${product.name}. Available: ${product.stock}`);
      }

      // Price validation (compare client-sent price with actual price)
      if (item.price !== product.price) {
        throw new Error(`Price mismatch for ${product.name}. Current price: ${product.price}`);
      }
    }

    // Total amount validation
    const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

    if (totalAmount < 1000) {
      throw new Error("Minimum order amount is 1000");
    }

    if (totalAmount > 10000000) {
      throw new Error("Maximum order amount is 10,000,000");
    }
  }

  @api({ httpMethod: "POST" })
  async create(params: CreateOrderParams): Promise<{ orderId: number }> {
    await this.validateOrderItems(params.items);

    // ...
  }
}

Permission Validation

class DocumentModel extends BaseModelClass {
  private async validateAccess(
    documentId: number,
    requiredPermission: "read" | "write" | "delete",
  ): Promise<void> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const rdb = this.getPuri("r");

    // Get document
    const document = await rdb.table("documents").where("id", documentId).first();

    if (!document) {
      throw new Error("Document not found");
    }

    // Owner has all permissions
    if (document.owner_id === context.user.id) {
      return;
    }

    // Admin has all permissions
    if (context.user.role === "admin") {
      return;
    }

    // Check permission
    const permission = await rdb
      .table("document_permissions")
      .where("document_id", documentId)
      .where("user_id", context.user.id)
      .first();

    if (!permission) {
      throw new Error("Access denied");
    }

    const permissionLevel = {
      read: 1,
      write: 2,
      delete: 3,
    };

    if (permissionLevel[permission.level] < permissionLevel[requiredPermission]) {
      throw new Error(`Insufficient permission: ${requiredPermission} required`);
    }
  }

  @api({ httpMethod: "GET" })
  async get(documentId: number): Promise<Document> {
    await this.validateAccess(documentId, "read");

    const rdb = this.getPuri("r");
    return rdb.table("documents").where("id", documentId).first();
  }

  @api({ httpMethod: "PUT" })
  async update(documentId: number, params: UpdateDocumentParams): Promise<void> {
    await this.validateAccess(documentId, "write");

    const wdb = this.getPuri("w");
    await wdb.table("documents").where("id", documentId).update(params);
  }

  @api({ httpMethod: "DELETE" })
  async remove(documentId: number): Promise<void> {
    await this.validateAccess(documentId, "delete");

    const wdb = this.getPuri("w");
    await wdb.table("documents").where("id", documentId).delete();
  }
}

External Service Validation

Email Validation (External API)

import axios from "axios";

class EmailValidator {
  private apiKey = process.env.EMAIL_VALIDATION_API_KEY;

  async validateEmail(email: string): Promise<void> {
    try {
      const response = await axios.get(`https://api.emailvalidation.com/validate`, {
        params: {
          email,
          apiKey: this.apiKey,
        },
        timeout: 5000,
      });

      const { valid, reason } = response.data;

      if (!valid) {
        throw new Error(`Invalid email: ${reason}`);
      }
    } catch (error) {
      // On API failure, perform basic validation only
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(email)) {
        throw new Error("Invalid email format");
      }
    }
  }
}

Address Validation (External API)

class AddressValidator {
  async validateAddress(address: {
    street: string;
    city: string;
    zipCode: string;
    country: string;
  }): Promise<void> {
    try {
      const response = await axios.post("https://api.address-verification.com/verify", address, {
        timeout: 5000,
      });

      if (!response.data.valid) {
        throw new Error("Address could not be verified");
      }
    } catch (error) {
      throw new Error("Address validation failed");
    }
  }
}

Combining Validations

Running Multiple Validations Together

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    // 1. Zod schema validation
    const validated = CreateUserSchema.parse(params);

    // 2. Custom validation
    await this.validateCreateParams(validated);

    // 3. Create
    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });

    return { userId: user.id };
  }

  private async validateCreateParams(params: CreateUserParams): Promise<void> {
    const rdb = this.getPuri("r");
    const validator = new UserValidator(rdb);

    // Parallel validation (independent validations)
    await Promise.all([
      validator.validateEmail(params.email),
      validator.validateUsername(params.username),
    ]);

    // Sequential validation (password is synchronous)
    validator.validatePassword(params.password);
  }
}

Base Validator Class

// validators/base.validator.ts
export abstract class BaseValidator {
  protected rdb: any;

  constructor(rdb: any) {
    this.rdb = rdb;
  }

  protected async checkUnique(
    table: string,
    field: string,
    value: any,
    excludeId?: number,
  ): Promise<void> {
    let query = this.rdb.table(table).where(field, value);

    if (excludeId) {
      query = query.whereNot("id", excludeId);
    }

    const existing = await query.first();

    if (existing) {
      throw new Error(`${field} already exists`);
    }
  }

  protected async checkExists(table: string, id: number): Promise<void> {
    const record = await this.rdb.table(table).where("id", id).first();

    if (!record) {
      throw new Error(`${table} not found`);
    }
  }

  protected validateLength(value: string, min: number, max: number, fieldName: string): void {
    if (value.length < min || value.length > max) {
      throw new Error(`${fieldName} must be ${min}-${max} characters`);
    }
  }

  protected validatePattern(
    value: string,
    pattern: RegExp,
    fieldName: string,
    message: string,
  ): void {
    if (!pattern.test(value)) {
      throw new Error(`${fieldName}: ${message}`);
    }
  }
}

// Usage
class UserValidator extends BaseValidator {
  async validateEmail(email: string): Promise<void> {
    this.validatePattern(email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Email", "Invalid format");

    await this.checkUnique("users", "email", email);
  }
}

Cautions

Cautions for custom validation: 1. Run validation before business logic 2. Provide clear error messages 3. Avoid excessive DB queries 4. Set timeouts for external API calls 5. Consider validation logic reuse

Next Steps

Automatic Validation

Zod-based validation

Error Handling

Using SonamuError

@api Decorator

API basic usage

Parameters

API parameter definitions