Products Domain
Complete product catalog implementation with data models, services, and state management
Products Domain
Production-ready product catalog patterns with type-safe data models and React Query integration
Overview
This domain demonstrates a complete product catalog system including data models, API services, caching strategies, and UI state management using React Query.
Implementation Files
Core Types
// core/domains/products/types.ts
export interface Product {
id: string;
name: string;
description: string;
price: number;
currency: string;
images: ProductImage[];
category: ProductCategory;
brand: string;
sku: string;
stock: number;
isActive: boolean;
rating: number;
reviewCount: number;
tags: string[];
variants?: ProductVariant[];
metadata: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface ProductImage {
id: string;
url: string;
alt: string;
width: number;
height: number;
isPrimary: boolean;
}
export interface ProductCategory {
id: string;
name: string;
slug: string;
parentId: string | null;
level: number;
imageUrl?: string;
}
export interface ProductVariant {
id: string;
name: string;
sku: string;
price: number;
stock: number;
attributes: ProductAttribute[];
}
export interface ProductAttribute {
name: string;
value: string;
type: 'color' | 'size' | 'material' | 'style';
}
export interface ProductFilters {
categoryId?: string;
brandIds?: string[];
priceRange?: {
min: number;
max: number;
};
rating?: number;
tags?: string[];
inStock?: boolean;
search?: string;
}
export interface ProductSortOptions {
field: 'name' | 'price' | 'rating' | 'createdAt';
direction: 'asc' | 'desc';
}
export interface ProductListResponse {
products: Product[];
pagination: {
page: number;
limit: number;
total: number;
hasNext: boolean;
hasPrevious: boolean;
};
filters: ProductFilters;
sort: ProductSortOptions;
}
export interface CreateProductData {
name: string;
description: string;
price: number;
currency: string;
categoryId: string;
brand: string;
sku: string;
stock: number;
images: Omit<ProductImage, 'id'>[];
tags?: string[];
variants?: Omit<ProductVariant, 'id'>[];
}
export interface UpdateProductData extends Partial<CreateProductData> {
isActive?: boolean;
}Service Layer
// core/domains/products/services/productService.ts
import { ApiClient } from '@/core/shared/api/ApiClient';
import type {
Product,
ProductListResponse,
ProductFilters,
ProductSortOptions,
CreateProductData,
UpdateProductData,
ProductCategory,
} from '../types';
export class ProductService {
private apiClient: ApiClient;
constructor() {
this.apiClient = new ApiClient();
}
async getProducts(
filters: ProductFilters = {},
sort: ProductSortOptions = { field: 'createdAt', direction: 'desc' },
page: number = 1,
limit: number = 20
): Promise<ProductListResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
sortField: sort.field,
sortDirection: sort.direction,
});
// Add filters to params
if (filters.categoryId) {
params.append('categoryId', filters.categoryId);
}
if (filters.brandIds?.length) {
filters.brandIds.forEach(brandId => params.append('brandIds[]', brandId));
}
if (filters.priceRange) {
params.append('minPrice', filters.priceRange.min.toString());
params.append('maxPrice', filters.priceRange.max.toString());
}
if (filters.rating) {
params.append('minRating', filters.rating.toString());
}
if (filters.tags?.length) {
filters.tags.forEach(tag => params.append('tags[]', tag));
}
if (filters.inStock !== undefined) {
params.append('inStock', filters.inStock.toString());
}
if (filters.search) {
params.append('search', filters.search);
}
return this.apiClient.get(`/products?${params.toString()}`);
}
async getProduct(id: string): Promise<Product> {
return this.apiClient.get(`/products/${id}`);
}
async createProduct(data: CreateProductData): Promise<Product> {
return this.apiClient.post('/products', data);
}
async updateProduct(id: string, data: UpdateProductData): Promise<Product> {
return this.apiClient.patch(`/products/${id}`, data);
}
async deleteProduct(id: string): Promise<void> {
return this.apiClient.delete(`/products/${id}`);
}
async getCategories(): Promise<ProductCategory[]> {
return this.apiClient.get('/products/categories');
}
async getFeaturedProducts(limit: number = 10): Promise<Product[]> {
return this.apiClient.get(`/products/featured?limit=${limit}`);
}
async getRecommendedProducts(productId: string, limit: number = 10): Promise<Product[]> {
return this.apiClient.get(`/products/${productId}/recommended?limit=${limit}`);
}
async searchProducts(query: string, limit: number = 20): Promise<Product[]> {
const params = new URLSearchParams({
q: query,
limit: limit.toString(),
});
return this.apiClient.get(`/products/search?${params.toString()}`);
}
async getProductsByCategory(categoryId: string, limit: number = 20): Promise<Product[]> {
return this.apiClient.get(`/products/categories/${categoryId}/products?limit=${limit}`);
}
async uploadProductImage(productId: string, imageFile: File): Promise<ProductImage> {
const formData = new FormData();
formData.append('image', imageFile);
return this.apiClient.post(`/products/${productId}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
async deleteProductImage(productId: string, imageId: string): Promise<void> {
return this.apiClient.delete(`/products/${productId}/images/${imageId}`);
}
}React Query Hooks
// core/domains/products/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ProductService } from '../services/productService';
import type {
Product,
ProductFilters,
ProductSortOptions,
CreateProductData,
UpdateProductData,
} from '../types';
const productService = new ProductService();
// Query Keys
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters, sort: ProductSortOptions, page: number) =>
[...productKeys.lists(), { filters, sort, page }] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
categories: () => [...productKeys.all, 'categories'] as const,
featured: () => [...productKeys.all, 'featured'] as const,
recommended: (productId: string) => [...productKeys.all, 'recommended', productId] as const,
search: (query: string) => [...productKeys.all, 'search', query] as const,
};
// Hooks
export function useProducts(
filters: ProductFilters = {},
sort: ProductSortOptions = { field: 'createdAt', direction: 'desc' },
page: number = 1,
options: { enabled?: boolean } = {}
) {
return useQuery({
queryKey: productKeys.list(filters, sort, page),
queryFn: () => productService.getProducts(filters, sort, page),
enabled: options.enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});
}
export function useProduct(id: string, options: { enabled?: boolean } = {}) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => productService.getProduct(id),
enabled: options.enabled && !!id,
staleTime: 5 * 60 * 1000,
cacheTime: 30 * 60 * 1000, // Cache longer for individual products
});
}
export function useProductCategories() {
return useQuery({
queryKey: productKeys.categories(),
queryFn: () => productService.getCategories(),
staleTime: 30 * 60 * 1000, // Categories change less frequently
cacheTime: 60 * 60 * 1000,
});
}
export function useFeaturedProducts() {
return useQuery({
queryKey: productKeys.featured(),
queryFn: () => productService.getFeaturedProducts(),
staleTime: 10 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
});
}
export function useRecommendedProducts(productId: string) {
return useQuery({
queryKey: productKeys.recommended(productId),
queryFn: () => productService.getRecommendedProducts(productId),
enabled: !!productId,
staleTime: 15 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
});
}
export function useProductSearch(query: string, options: { enabled?: boolean } = {}) {
return useQuery({
queryKey: productKeys.search(query),
queryFn: () => productService.searchProducts(query),
enabled: options.enabled && query.length > 2,
staleTime: 2 * 60 * 1000, // Search results change more frequently
cacheTime: 5 * 60 * 1000,
});
}
// Mutations
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductData) => productService.createProduct(data),
onSuccess: () => {
// Invalidate all product lists
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
queryClient.invalidateQueries({ queryKey: productKeys.categories() });
},
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductData }) =>
productService.updateProduct(id, data),
onSuccess: (updatedProduct) => {
// Update the specific product in cache
queryClient.setQueryData(
productKeys.detail(updatedProduct.id),
updatedProduct
);
// Invalidate product lists to reflect changes
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
});
}
export function useDeleteProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => productService.deleteProduct(id),
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: productKeys.detail(deletedId) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
});
}Custom Hooks for Common Patterns
// core/domains/products/hooks/useProductFilters.ts
import { useState, useCallback, useMemo } from 'react';
import type { ProductFilters, ProductSortOptions } from '../types';
export function useProductFilters(initialFilters: ProductFilters = {}) {
const [filters, setFilters] = useState<ProductFilters>(initialFilters);
const [sort, setSort] = useState<ProductSortOptions>({
field: 'createdAt',
direction: 'desc',
});
const updateFilter = useCallback(<K extends keyof ProductFilters>(
key: K,
value: ProductFilters[K]
) => {
setFilters(prev => ({
...prev,
[key]: value,
}));
}, []);
const removeFilter = useCallback((key: keyof ProductFilters) => {
setFilters(prev => {
const newFilters = { ...prev };
delete newFilters[key];
return newFilters;
});
}, []);
const clearFilters = useCallback(() => {
setFilters({});
}, []);
const updateSort = useCallback((newSort: ProductSortOptions) => {
setSort(newSort);
}, []);
const activeFilterCount = useMemo(() => {
return Object.keys(filters).length;
}, [filters]);
const hasActiveFilters = useMemo(() => {
return activeFilterCount > 0;
}, [activeFilterCount]);
return {
filters,
sort,
updateFilter,
removeFilter,
clearFilters,
updateSort,
activeFilterCount,
hasActiveFilters,
};
}// core/domains/products/hooks/useProductCart.ts
import { useState, useCallback } from 'react';
import type { Product, ProductVariant } from '../types';
export interface CartItem {
product: Product;
variant?: ProductVariant;
quantity: number;
addedAt: string;
}
export function useProductCart() {
const [cartItems, setCartItems] = useState<CartItem[]>([]);
const addToCart = useCallback((
product: Product,
quantity: number = 1,
variant?: ProductVariant
) => {
setCartItems(prev => {
const existingIndex = prev.findIndex(item =>
item.product.id === product.id &&
item.variant?.id === variant?.id
);
if (existingIndex >= 0) {
// Update existing item
const newItems = [...prev];
newItems[existingIndex] = {
...newItems[existingIndex],
quantity: newItems[existingIndex].quantity + quantity,
};
return newItems;
} else {
// Add new item
return [...prev, {
product,
variant,
quantity,
addedAt: new Date().toISOString(),
}];
}
});
}, []);
const removeFromCart = useCallback((productId: string, variantId?: string) => {
setCartItems(prev =>
prev.filter(item =>
!(item.product.id === productId && item.variant?.id === variantId)
)
);
}, []);
const updateQuantity = useCallback((
productId: string,
quantity: number,
variantId?: string
) => {
if (quantity <= 0) {
removeFromCart(productId, variantId);
return;
}
setCartItems(prev =>
prev.map(item =>
item.product.id === productId && item.variant?.id === variantId
? { ...item, quantity }
: item
)
);
}, [removeFromCart]);
const clearCart = useCallback(() => {
setCartItems([]);
}, []);
const getCartTotal = useCallback(() => {
return cartItems.reduce((total, item) => {
const price = item.variant?.price || item.product.price;
return total + (price * item.quantity);
}, 0);
}, [cartItems]);
const getCartItemCount = useCallback(() => {
return cartItems.reduce((count, item) => count + item.quantity, 0);
}, [cartItems]);
return {
cartItems,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
getCartTotal,
getCartItemCount,
};
}Usage Examples
Product List Component
// features/product-catalog/components/ProductList.tsx
import React from 'react';
import { FlatList, View, Text } from 'react-native';
import { useProducts } from '@/core/domains/products/hooks/useProducts';
import { ProductCard } from './ProductCard';
import { LoadingSpinner } from '@/ui/foundation/LoadingSpinner';
import type { Product, ProductFilters, ProductSortOptions } from '@/core/domains/products/types';
interface ProductListProps {
filters?: ProductFilters;
sort?: ProductSortOptions;
onProductPress?: (product: Product) => void;
}
export function ProductList({
filters = {},
sort = { field: 'createdAt', direction: 'desc' },
onProductPress
}: ProductListProps) {
const { data, isLoading, error, fetchNextPage, hasNextPage } = useProducts(filters, sort);
if (isLoading && !data) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<LoadingSpinner />
<Text style={{ marginTop: 16 }}>Loading products...</Text>
</View>
);
}
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Error loading products: {error.message}</Text>
</View>
);
}
const products = data?.products || [];
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ProductCard
product={item}
onPress={() => onProductPress?.(item)}
/>
)}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.1}
ListEmptyComponent={
<View style={{ padding: 20, alignItems: 'center' }}>
<Text>No products found</Text>
</View>
}
contentContainerStyle={{ padding: 16 }}
/>
);
}Product Detail Component
// features/product-catalog/components/ProductDetail.tsx
import React from 'react';
import { ScrollView, View, Text, Image } from 'react-native';
import { useProduct } from '@/core/domains/products/hooks/useProducts';
import { useProductCart } from '@/core/domains/products/hooks/useProductCart';
import { Button } from '@/ui/foundation/Button';
import { LoadingSpinner } from '@/ui/foundation/LoadingSpinner';
interface ProductDetailProps {
productId: string;
}
export function ProductDetail({ productId }: ProductDetailProps) {
const { data: product, isLoading, error } = useProduct(productId);
const { addToCart } = useProductCart();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<LoadingSpinner />
</View>
);
}
if (error || !product) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Product not found</Text>
</View>
);
}
const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
return (
<ScrollView style={{ flex: 1 }}>
{primaryImage && (
<Image
source={{ uri: primaryImage.url }}
style={{ width: '100%', height: 300 }}
resizeMode="cover"
/>
)}
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
{product.name}
</Text>
<Text style={{ fontSize: 20, color: '#007AFF', marginBottom: 16 }}>
{product.currency} {product.price.toFixed(2)}
</Text>
<Text style={{ fontSize: 16, lineHeight: 24, marginBottom: 16 }}>
{product.description}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 16 }}>
<Text>Rating: {product.rating}/5</Text>
<Text style={{ marginLeft: 16 }}>
({product.reviewCount} reviews)
</Text>
</View>
<Text style={{ marginBottom: 16 }}>
Stock: {product.stock > 0 ? `${product.stock} available` : 'Out of stock'}
</Text>
<Button
onPress={() => addToCart(product)}
disabled={product.stock === 0}
style={{ marginTop: 16 }}
>
{product.stock > 0 ? 'Add to Cart' : 'Out of Stock'}
</Button>
</View>
</ScrollView>
);
}Testing Patterns
// core/domains/products/__tests__/useProducts.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useProducts } from '../hooks/useProducts';
import { ProductService } from '../services/productService';
// Mock the service
jest.mock('../services/productService');
const mockProductService = ProductService as jest.MockedClass<typeof ProductService>;
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useProducts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches products successfully', async () => {
const mockResponse = {
products: [
{ id: '1', name: 'Test Product', price: 29.99 },
],
pagination: { page: 1, limit: 20, total: 1, hasNext: false, hasPrevious: false },
};
mockProductService.prototype.getProducts.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useProducts(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockResponse);
expect(mockProductService.prototype.getProducts).toHaveBeenCalledWith(
{},
{ field: 'createdAt', direction: 'desc' },
1
);
});
it('applies filters correctly', async () => {
const filters = { categoryId: 'cat-1', search: 'test' };
const sort = { field: 'price', direction: 'asc' } as const;
mockProductService.prototype.getProducts.mockResolvedValue({
products: [],
pagination: { page: 1, limit: 20, total: 0, hasNext: false, hasPrevious: false },
});
renderHook(() => useProducts(filters, sort), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(mockProductService.prototype.getProducts).toHaveBeenCalledWith(
filters,
sort,
1
);
});
});
});Best Practices
Performance
- ✅ Use React Query for caching and background updates
- ✅ Implement pagination for large product lists
- ✅ Cache product images and optimize sizes
- ✅ Use proper stale times based on data volatility
Type Safety
- ✅ Define comprehensive TypeScript interfaces
- ✅ Use discriminated unions for variants
- ✅ Type-safe filter and sort operations
- ✅ Proper error type definitions
Data Management
- ✅ Normalize product data structure
- ✅ Implement optimistic updates for mutations
- ✅ Handle offline scenarios gracefully
- ✅ Cache frequently accessed data
Ready to use? Copy these patterns into your src/core/domains/products/ directory and customize the data models and API endpoints for your specific product catalog needs.