-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Problem
The ProductService class contains 4 validation methods that perform multi-tenant database queries to validate business rules:
validateBusinessRules()- Validates SKU/slug uniqueness, category/brand existence, price constraintsvalidateVariantSkus()- Validates variant SKU uniqueness for new productsvalidateVariantSkusForUpdate()- Validates variant SKU uniqueness for product updatesgenerateUniqueSlug()- 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:
- Generic uniqueness validators that work for any entity
- Reference validators for foreign key relationships
- Business rule validators for domain constraints
- 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
- Single Responsibility: Validators have one job - validate data
- Reusability: Use in ProductService, CategoryService, BrandService, API routes
- Testability: Test validators without Prisma/database mocking complexity
- Maintainability: Validation logic changes in one place
- Type Safety: Generic validators provide strong TypeScript inference
- Composability: Build complex validations from simple, focused functions
- 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
- Unit tests: Test each validator function independently
validateUniquewith various modelsvalidateBatchUniquewith duplicates, collisionsvalidateReferencewith valid/invalid IDsgenerateUniqueSlugwith collision handling
- Integration tests: Test validators with real Prisma/database
- Service tests: Verify ProductService still validates correctly after refactoring
- Cross-service tests: Ensure validators work consistently across Product, Category, Brand services
- Performance tests: Measure validation query performance (should be similar or better)
Migration Strategy
- Phase 1: Create validator modules alongside existing code
- Phase 2: Refactor ProductService to use validators, run full test suite
- Phase 3: Refactor CategoryService, BrandService with validators
- Phase 4: Update API routes to use validators for request validation
- 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
Type
Projects
Status