Skip to content

[Refactoring] Extract Entity Validation Logic into Reusable Validator Utilities #277

@syed-reza98

Description

@syed-reza98

Problem

The ProductService class contains 4 validation methods that perform multi-tenant database queries to validate business rules:

  1. validateBusinessRules() - Validates SKU/slug uniqueness, category/brand existence, price constraints
  2. validateVariantSkus() - Validates variant SKU uniqueness for new products
  3. validateVariantSkusForUpdate() - Validates variant SKU uniqueness for product updates
  4. generateUniqueSlug() - Generates unique slugs with collision detection

These methods are tightly coupled to the ProductService and not reusable by other services, leading to:

  • Code duplication: Other services (CategoryService, BrandService) implement similar validation patterns
  • Testing complexity: Validation logic requires full ProductService instantiation
  • Poor separation of concerns: Business rule validation is mixed with CRUD operations
  • Limited reusability: Validation cannot be used in API routes, middleware, or other services
  • Inconsistent validation: Different services may validate the same constraints differently

Current Code Location

  • File: src/lib/services/product.service.ts (lines 1329-1479)
  • Methods: validateBusinessRules, validateVariantSkus, validateVariantSkusForUpdate, generateUniqueSlug
  • Complexity: High - 150+ lines of validation logic embedded in service class

Proposed Refactoring

Extract validation logic into standalone, composable validator utilities

Create a dedicated validation module that provides:

  1. Generic uniqueness validators that work for any entity
  2. Reference validators for foreign key relationships
  3. Business rule validators for domain constraints
  4. Utility functions for slug generation

This enables:

  • Reusability across services, API routes, and middleware
  • Composability - combine validators to build complex validation chains
  • Testability - test validators independently without service dependencies
  • Consistency - all services use the same validation logic

Benefits

  1. Single Responsibility: Validators have one job - validate data
  2. Reusability: Use in ProductService, CategoryService, BrandService, API routes
  3. Testability: Test validators without Prisma/database mocking complexity
  4. Maintainability: Validation logic changes in one place
  5. Type Safety: Generic validators provide strong TypeScript inference
  6. Composability: Build complex validations from simple, focused functions
  7. Documentation: Clear interfaces explain validation rules

Suggested Approach

Step 1: Create Validation Utilities Module

Create src/lib/validation/entity-validators.ts:

import { prisma } from '@/lib/prisma';
import { Prisma } from '`@prisma/client`';

/**
 * Generic uniqueness validator for any Prisma model
 * 
 * `@example`
 * await validateUnique({
 *   model: 'product',
 *   field: 'sku',
 *   value: 'PROD-123',
 *   storeId: 'store-id',
 *   excludeId: 'product-id',
 * });
 */
export async function validateUnique(TModel extends Prisma.ModelName)({
  model,
  field,
  value,
  storeId,
  excludeId,
  errorMessage,
}: {
  model: TModel;
  field: string;
  value: string;
  storeId: string;
  excludeId?: string;
  errorMessage?: string;
}): Promise(void) {
  const where: Record(string, unknown) = {
    [field]: value,
    storeId,
    deletedAt: null,
  };

  if (excludeId) {
    where.id = { not: excludeId };
  }

  const existing = await (prisma[model.toLowerCase() as Lowercase(TModel)] as any).findFirst({
    where,
    select: { id: true },
  });

  if (existing) {
    throw new Error(errorMessage || `\$\{field} '\$\{value}' already exists`);
  }
}

/**
 * Batch uniqueness validator for arrays of values
 * 
 * `@example`
 * await validateBatchUnique({
 *   model: 'productVariant',
 *   field: 'sku',
 *   values: ['SKU-1', 'SKU-2'],
 *   storeId: 'store-id',
 *   joinField: 'product',
 * });
 */
export async function validateBatchUnique(TModel extends Prisma.ModelName)({
  model,
  field,
  values,
  storeId,
  excludeIds = [],
  joinField,
}: {
  model: TModel;
  field: string;
  values: string[];
  storeId: string;
  excludeIds?: string[];
  joinField?: string; // For nested relations (e.g., product.storeId)
}): Promise(void) {
  if (values.length === 0) return;

  // Check for duplicates in input
  const uniqueValues = new Set(values);
  if (uniqueValues.size !== values.length) {
    const duplicates = values.filter((v, i) => values.indexOf(v) !== i);
    throw new Error(`Duplicate values: \$\{[...new Set(duplicates)].join(', ')}`);
  }

  // Check database
  const where: Record(string, unknown) = {
    [field]: { in: values },
  };

  if (joinField) {
    where[joinField] = { storeId };
  } else {
    where.storeId = storeId;
  }

  if (excludeIds.length > 0) {
    where.id = { notIn: excludeIds };
  }

  const existing = await (prisma[model.toLowerCase() as Lowercase(TModel)] as any).findMany({
    where,
    select: { [field]: true },
  });

  if (existing.length > 0) {
    const existingValues = existing.map((e: any) => e[field]);
    throw new Error(`Values already exist: \$\{existingValues.join(', ')}`);
  }
}

/**
 * Validates that a referenced entity exists
 * 
 * `@example`
 * await validateReference({
 *   model: 'category',
 *   id: 'cat-id',
 *   storeId: 'store-id',
 *   errorMessage: 'Category not found',
 * });
 */
export async function validateReference(TModel extends Prisma.ModelName)({
  model,
  id,
  storeId,
  errorMessage,
}: {
  model: TModel;
  id: string;
  storeId: string;
  errorMessage?: string;
}): Promise(void) {
  const entity = await (prisma[model.toLowerCase() as Lowercase(TModel)] as any).findFirst({
    where: { id, storeId, deletedAt: null },
    select: { id: true },
  });

  if (!entity) {
    throw new Error(errorMessage || `\$\{model} not found`);
  }
}

Step 2: Create Slug Generation Utility

Create src/lib/validation/slug-generator.ts:

import { prisma } from '@/lib/prisma';
import { Prisma } from '`@prisma/client`';

/**
 * Generates a unique slug for any Prisma model
 * Automatically handles collisions by appending numbers
 * 
 * `@example`
 * const slug = await generateUniqueSlug({
 *   model: 'product',
 *   baseText: 'Blue T-Shirt',
 *   storeId: 'store-id',
 *   excludeId: 'existing-product-id',
 * });
 * // Returns: 'blue-t-shirt' or 'blue-t-shirt-2' if collision
 */
export async function generateUniqueSlug(TModel extends Prisma.ModelName)({
  model,
  baseText,
  storeId,
  excludeId,
  maxAttempts = 100,
}: {
  model: TModel;
  baseText: string;
  storeId: string;
  excludeId?: string;
  maxAttempts?: number;
}): Promise(string) {
  const baseSlug = baseText
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .trim();

  let slug = baseSlug;
  let counter = 1;
  let attempts = 0;

  while (attempts < maxAttempts) {
    const where: Record(string, unknown) = {
      slug,
      storeId,
      deletedAt: null,
    };

    if (excludeId) {
      where.id = { not: excludeId };
    }

    const existing = await (prisma[model.toLowerCase() as Lowercase(TModel)] as any).findFirst({
      where,
      select: { id: true },
    });

    if (!existing) {
      return slug;
    }

    slug = `\$\{baseSlug}-\$\{counter}`;
    counter++;
    attempts++;
  }

  throw new Error(`Failed to generate unique slug after \$\{maxAttempts} attempts`);
}

Step 3: Create Business Rule Validators

Create src/lib/validation/business-rules.ts:

/**
 * Validates product-specific business rules
 */
export function validateProductPricing({
  price,
  compareAtPrice,
}: {
  price?: number;
  compareAtPrice?: number | null;
}): void {
  if (
    price !== undefined &&
    compareAtPrice !== undefined &&
    compareAtPrice !== null
  ) {
    if (compareAtPrice <= price) {
      throw new Error('Compare at price must be greater than regular price');
    }
  }
}

/**
 * Validates discount value constraints
 */
export function validateDiscount({
  discountType,
  discountValue,
}: {
  discountType?: string;
  discountValue?: number | null;
}): void {
  if (discountType === 'PERCENTAGE' && discountValue && discountValue > 100) {
    throw new Error('Percentage discount cannot exceed 100%');
  }
}

Step 4: Refactor ProductService to Use Validators

Replace inline validation with extracted utilities:

import { validateUnique, validateBatchUnique, validateReference } from '@/lib/validation/entity-validators';
import { generateUniqueSlug } from '@/lib/validation/slug-generator';
import { validateProductPricing } from '@/lib/validation/business-rules';

class ProductService {
  async createProduct(storeId: string, data: CreateProductData) {
    // Validate SKU uniqueness
    if (data.sku) {
      await validateUnique({
        model: 'Product',
        field: 'sku',
        value: data.sku,
        storeId,
        errorMessage: `SKU '\$\{data.sku}' already exists in this store`,
      });
    }

    // Generate unique slug
    const slug = data.slug || await generateUniqueSlug({
      model: 'Product',
      baseText: data.name,
      storeId,
    });

    // Validate category reference
    if (data.categoryId) {
      await validateReference({
        model: 'Category',
        id: data.categoryId,
        storeId,
        errorMessage: 'Category not found',
      });
    }

    // Validate brand reference
    if (data.brandId) {
      await validateReference({
        model: 'Brand',
        id: data.brandId,
        storeId,
        errorMessage: 'Brand not found',
      });
    }

    // Validate pricing
    validateProductPricing({
      price: data.price,
      compareAtPrice: data.compareAtPrice,
    });

    // Validate variant SKUs
    if (data.variants && data.variants.length > 0) {
      await validateBatchUnique({
        model: 'ProductVariant',
        field: 'sku',
        values: data.variants.map(v => v.sku),
        storeId,
        joinField: 'product',
      });
    }

    // Create product...
  }
}

Step 5: Apply Same Pattern to Other Services

Use validators in CategoryService, BrandService, StoreService:

// src/lib/services/category.service.ts
import { validateUnique, generateUniqueSlug } from '@/lib/validation';

class CategoryService {
  async createCategory(storeId: string, data: CreateCategoryData) {
    await validateUnique({
      model: 'Category',
      field: 'slug',
      value: data.slug,
      storeId,
    });

    const slug = data.slug || await generateUniqueSlug({
      model: 'Category',
      baseText: data.name,
      storeId,
    });

    // Create category...
  }
}

Code Example

Before (in ProductService - 150+ lines of validation):

private async validateBusinessRules(
  storeId: string,
  data: Partial(CreateProductData),
  excludeId?: string
): Promise(void) {
  const [skuExists, slugExists, categoryExists, brandExists] = await Promise.all([
    data.sku
      ? prisma.product.findFirst({
          where: {
            storeId,
            sku: data.sku,
            deletedAt: null,
            ...(excludeId && { id: { not: excludeId } }),
          },
          select: { id: true },
        })
      : Promise.resolve(null),
    data.slug
      ? prisma.product.findFirst({
          where: {
            storeId,
            slug: data.slug,
            deletedAt: null,
            ...(excludeId && { id: { not: excludeId } }),
          },
          select: { id: true },
        })
      : Promise.resolve(null),
    // ... more validation queries
  ]);

  if (data.sku && skuExists) {
    throw new Error(`SKU '\$\{data.sku}' already exists`);
  }
  // ... more validation checks
}

After (using validators - ~10 lines):

import { validateUnique, validateReference } from '@/lib/validation';

async createProduct(storeId: string, data: CreateProductData) {
  // Concise, reusable validation
  await validateUnique({
    model: 'Product',
    field: 'sku',
    value: data.sku,
    storeId,
  });

  if (data.categoryId) {
    await validateReference({
      model: 'Category',
      id: data.categoryId,
      storeId,
    });
  }

  validateProductPricing({ price: data.price, compareAtPrice: data.compareAtPrice });

  // Create product...
}

Impact Assessment

  • Effort: Medium-High - Requires creating 3 new modules and refactoring 4+ services
  • Risk: Medium - Validation logic is critical; requires thorough testing
  • Benefit: Very High - Enables reuse across all services, API routes, and middleware
  • Priority: High - Foundational improvement that unlocks consistency and reduces duplication

Related Files

  • src/lib/services/product.service.ts (primary - 1,662 lines)
  • src/lib/services/category.service.ts (739 lines - similar validation patterns)
  • src/lib/services/inventory.service.ts (1,347 lines - SKU validation)
  • src/lib/services/order.service.ts (858 lines - reference validation)
  • src/app/api/products/route.ts (API route - can use validators)

Testing Strategy

  1. Unit tests: Test each validator function independently
    • validateUnique with various models
    • validateBatchUnique with duplicates, collisions
    • validateReference with valid/invalid IDs
    • generateUniqueSlug with collision handling
  2. Integration tests: Test validators with real Prisma/database
  3. Service tests: Verify ProductService still validates correctly after refactoring
  4. Cross-service tests: Ensure validators work consistently across Product, Category, Brand services
  5. Performance tests: Measure validation query performance (should be similar or better)

Migration Strategy

  1. Phase 1: Create validator modules alongside existing code
  2. Phase 2: Refactor ProductService to use validators, run full test suite
  3. Phase 3: Refactor CategoryService, BrandService with validators
  4. Phase 4: Update API routes to use validators for request validation
  5. Phase 5: Remove old validation methods from services

Additional Context

This refactoring follows Dependency Inversion and Open/Closed Principles:

  • Services depend on abstractions (validators), not concrete implementations
  • Validators are open for extension (new rules), closed for modification

Real-world benefits:

  • Consistency: All services validate uniqueness the same way
  • API validation: Use validators in route handlers before hitting services
  • Middleware: Use validators in middleware for early request rejection
  • Background jobs: Use validators in queue workers for data integrity

This pattern can be extended to:

  • Authorization validators (canUserAccessResource)
  • Format validators (validateEmail, validatePhone)
  • Date/time validators (isValidDateRange)

AI generated by Daily Codebase Analyzer - Semantic Function Extraction & Refactoring

  • expires on Mar 7, 2026, 1:40 PM UTC

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions