Architecture Applied to an E-commerce
Published on December 23, 2025
Architecture Applied to an E-commerce
Designing a professional e-commerce isn’t just about creating an online store. It’s about building a system that can handle thousands of simultaneous transactions, maintain consistency of critical data, scale when traffic increases, and recover from failures without losing information.
In this article, we’ll design the complete architecture of an e-commerce from scratch. We’ll use NestJS as the framework, as it’s extremely robust, well-structured, and provides the tools needed to build scalable enterprise applications. NestJS has been an excellent choice for complex projects due to its modular architecture, native dependency injection, and microservices support.
We’ll cover: requirements analysis, architecture design, inventory handling, transaction processing, and scalability strategies. All with practical code examples and detailed explanations of each architectural decision.
Requirements Analysis
Before writing a line of code, we need to understand what we’re building.
Functional Requirements
-
Product Management
- Product catalog with categories
- Search and filtering
- Inventory (stock) management
- Prices and discounts
-
User Management
- Registration and authentication
- User profiles
- Order history
-
Shopping Cart
- Add/remove products
- Calculate totals
- Cart persistence
-
Order Processing
- Checkout
- Inventory validation
- Payment processing
- Order confirmation
-
Inventory Management
- Real-time updates
- Overselling prevention
- Low stock alerts
Non-Functional Requirements
-
Performance
- Response < 200ms for 95% of requests
- Support for 10,000 concurrent users
-
Availability
- 99.9% uptime
- Automatic failure recovery
-
Scalability
- Horizontal scaling
- Handle traffic spikes (Black Friday, etc.)
-
Security
- Payment data protection
- Secure authentication
- Fraud prevention
-
Consistency
- Don’t sell products without stock
- ACID transactions for payments
General Architecture
We’ll design a modular architecture based on microservices, but starting with a modular monolith structure that can evolve.
NestJS Module Structure
src/
├── modules/
│ ├── products/ # Product management
│ ├── users/ # User management
│ ├── cart/ # Shopping cart
│ ├── orders/ # Order processing
│ ├── inventory/ # Inventory management
│ ├── payments/ # Payment processing
│ └── notifications/ # Notifications
├── common/ # Shared utilities
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ ├── interceptors/
│ └── pipes/
├── config/ # Configuration
└── database/ # DB configuration
Architectural Principles
- Separation of Responsibilities: Each module has a single responsibility
- Dependency Inversion: We depend on abstractions, not implementations
- Domain-Driven Design: Business domain guides the structure
- CQRS: Separation of commands (write) and queries (read) where it makes sense
Products Module
The products module is the heart of the catalog. It must be fast for reads and consistent for writes.
Module Structure
// src/modules/products/products.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { ProductRepository } from './repositories/product.repository';
import { Product } from './entities/product.entity';
import { Category } from './entities/category.entity';
@Module({
imports: [TypeOrmModule.forFeature([Product, Category])],
controllers: [ProductsController],
providers: [ProductsService, ProductRepository],
exports: [ProductsService], // Export for use in other modules
})
export class ProductsModule {}
Product Entity
// src/modules/products/entities/product.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Category } from './category.entity';
import { CartItem } from '../../cart/entities/cart-item.entity';
@Entity('products')
@Index(['sku'], { unique: true })
@Index(['name', 'description']) // Composite index for search
export class Product {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
sku: string; // Stock Keeping Unit
@Column()
name: string;
@Column('text')
description: string;
@Column('decimal', { precision: 10, scale: 2 })
price: number;
@Column('decimal', { precision: 10, scale: 2, nullable: true })
discountPrice: number | null;
@Column({ default: 0 })
stock: number;
@Column({ default: true })
isActive: boolean;
@ManyToOne(() => Category, (category) => category.products, { eager: true })
category: Category;
@OneToMany(() => CartItem, (cartItem) => cartItem.product)
cartItems: CartItem[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// Domain methods
isInStock(): boolean {
return this.stock > 0;
}
getCurrentPrice(): number {
return this.discountPrice ?? this.price;
}
canPurchase(quantity: number): boolean {
return this.isActive && this.stock >= quantity;
}
}
Products Service
// src/modules/products/products.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { Product } from './entities/product.entity';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
) {}
async create(createProductDto: CreateProductDto): Promise<Product> {
const product = this.productRepository.create(createProductDto);
return await this.productRepository.save(product);
}
async findAll(query: ProductQueryDto): Promise<{
data: Product[];
total: number;
page: number;
limit: number;
}> {
const { page = 1, limit = 20, search, categoryId, minPrice, maxPrice } = query;
const skip = (page - 1) * limit;
const where: FindOptionsWhere<Product> = {
isActive: true,
};
if (categoryId) {
where.category = { id: categoryId };
}
if (minPrice || maxPrice) {
where.price = {};
if (minPrice) {
where.price = { ...where.price, $gte: minPrice } as any;
}
if (maxPrice) {
where.price = { ...where.price, $lte: maxPrice } as any;
}
}
const [data, total] = await this.productRepository.findAndCount({
where: search
? [
{ ...where, name: ILike(`%${search}%`) },
{ ...where, description: ILike(`%${search}%`) },
]
: where,
relations: ['category'],
skip,
take: limit,
order: { createdAt: 'DESC' },
});
return {
data,
total,
page,
limit,
};
}
async findOne(id: string): Promise<Product> {
const product = await this.productRepository.findOne({
where: { id },
relations: ['category'],
});
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
return product;
}
async findBySku(sku: string): Promise<Product> {
const product = await this.productRepository.findOne({
where: { sku },
});
if (!product) {
throw new NotFoundException(`Product with SKU ${sku} not found`);
}
return product;
}
async update(id: string, updateProductDto: UpdateProductDto): Promise<Product> {
const product = await this.findOne(id);
Object.assign(product, updateProductDto);
return await this.productRepository.save(product);
}
async remove(id: string): Promise<void> {
const product = await this.findOne(id);
// Soft delete: mark as inactive instead of deleting
product.isActive = false;
await this.productRepository.save(product);
}
}
Products Controller
// src/modules/products/products.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Get()
findAll(@Query() query: ProductQueryDto) {
return this.productsService.findAll(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.update(id, updateProductDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
Inventory Module
Inventory is critical. We must prevent overselling and maintain consistency.
Inventory Entity
// src/modules/inventory/entities/inventory.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToOne,
JoinColumn,
VersionColumn,
} from 'typeorm';
import { Product } from '../../products/entities/product.entity';
@Entity('inventory')
export class Inventory {
@PrimaryGeneratedColumn('uuid')
id: string;
@OneToOne(() => Product, { onDelete: 'CASCADE' })
@JoinColumn()
product: Product;
@Column()
productId: string;
@Column({ default: 0 })
quantity: number;
@Column({ default: 0 })
reserved: number; // Quantity reserved in carts
@Column({ default: 10 })
lowStockThreshold: number;
@VersionColumn() // Optimistic locking
version: number;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
lastUpdated: Date;
// Domain methods
getAvailable(): number {
return this.quantity - this.reserved;
}
isLowStock(): boolean {
return this.getAvailable() <= this.lowStockThreshold;
}
canReserve(quantity: number): boolean {
return this.getAvailable() >= quantity;
}
reserve(quantity: number): void {
if (!this.canReserve(quantity)) {
throw new Error('Insufficient stock available');
}
this.reserved += quantity;
this.lastUpdated = new Date();
}
release(quantity: number): void {
if (this.reserved < quantity) {
throw new Error('Cannot release more than reserved');
}
this.reserved -= quantity;
this.lastUpdated = new Date();
}
commit(quantity: number): void {
if (this.reserved < quantity) {
throw new Error('Cannot commit more than reserved');
}
this.reserved -= quantity;
this.quantity -= quantity;
this.lastUpdated = new Date();
}
}
Inventory Service with Optimistic Locking
// src/modules/inventory/inventory.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Inventory } from './entities/inventory.entity';
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(Inventory)
private readonly inventoryRepository: Repository<Inventory>,
private readonly dataSource: DataSource,
) {}
async reserveStock(productId: string, quantity: number): Promise<Inventory> {
// Use transaction to ensure consistency
return await this.dataSource.transaction(async (manager) => {
const inventory = await manager.findOne(Inventory, {
where: { productId },
lock: { mode: 'pessimistic_write' }, // Pessimistic lock for critical operations
});
if (!inventory) {
throw new NotFoundException(`Inventory for product ${productId} not found`);
}
if (!inventory.canReserve(quantity)) {
throw new ConflictException('Insufficient stock available');
}
inventory.reserve(quantity);
return await manager.save(inventory);
});
}
async releaseStock(productId: string, quantity: number): Promise<Inventory> {
return await this.dataSource.transaction(async (manager) => {
const inventory = await manager.findOne(Inventory, {
where: { productId },
lock: { mode: 'pessimistic_write' },
});
if (!inventory) {
throw new NotFoundException(`Inventory for product ${productId} not found`);
}
inventory.release(quantity);
return await manager.save(inventory);
});
}
async commitStock(productId: string, quantity: number): Promise<Inventory> {
return await this.dataSource.transaction(async (manager) => {
const inventory = await manager.findOne(Inventory, {
where: { productId },
lock: { mode: 'pessimistic_write' },
});
if (!inventory) {
throw new NotFoundException(`Inventory for product ${productId} not found`);
}
inventory.commit(quantity);
return await manager.save(inventory);
});
}
async getAvailableStock(productId: string): Promise<number> {
const inventory = await this.inventoryRepository.findOne({
where: { productId },
});
if (!inventory) {
return 0;
}
return inventory.getAvailable();
}
}
Cart Module
The cart must be fast and allow persistence.
Cart Entity
// src/modules/cart/entities/cart.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CartItem } from './entities/cart-item.entity';
@Entity('carts')
export class Cart {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User)
user: User;
@Column()
userId: string;
@OneToMany(() => CartItem, (item) => item.cart, { cascade: true, eager: true })
items: CartItem[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// Domain methods
getTotal(): number {
return this.items.reduce((total, item) => total + item.getSubtotal(), 0);
}
getItemCount(): number {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
Cart Item Entity
// src/modules/cart/entities/cart-item.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Cart } from './cart.entity';
import { Product } from '../../products/entities/product.entity';
@Entity('cart_items')
export class CartItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Cart, (cart) => cart.items, { onDelete: 'CASCADE' })
@JoinColumn()
cart: Cart;
@Column()
cartId: string;
@ManyToOne(() => Product)
@JoinColumn()
product: Product;
@Column()
productId: string;
@Column()
quantity: number;
@Column('decimal', { precision: 10, scale: 2 })
priceAtTime: number; // Price at time of adding to cart
// Domain methods
getSubtotal(): number {
return this.priceAtTime * this.quantity;
}
}
Cart Service
// src/modules/cart/cart.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Cart } from './entities/cart.entity';
import { CartItem } from './entities/cart-item.entity';
import { ProductsService } from '../products/products.service';
import { InventoryService } from '../inventory/inventory.service';
@Injectable()
export class CartService {
constructor(
@InjectRepository(Cart)
private readonly cartRepository: Repository<Cart>,
@InjectRepository(CartItem)
private readonly cartItemRepository: Repository<CartItem>,
private readonly productsService: ProductsService,
private readonly inventoryService: InventoryService,
private readonly dataSource: DataSource,
) {}
async getOrCreateCart(userId: string): Promise<Cart> {
let cart = await this.cartRepository.findOne({
where: { userId },
relations: ['items', 'items.product'],
});
if (!cart) {
cart = this.cartRepository.create({ userId, items: [] });
cart = await this.cartRepository.save(cart);
}
return cart;
}
async addItem(userId: string, productId: string, quantity: number): Promise<Cart> {
return await this.dataSource.transaction(async (manager) => {
// Check inventory availability
const available = await this.inventoryService.getAvailableStock(productId);
if (available < quantity) {
throw new NotFoundException('Insufficient stock');
}
// Get product
const product = await this.productsService.findOne(productId);
if (!product.isActive) {
throw new NotFoundException('Product is not available');
}
// Get or create cart
const cart = await this.getOrCreateCart(userId);
// Check if product is already in cart
const existingItem = cart.items.find((item) => item.productId === productId);
if (existingItem) {
// Update quantity
const newQuantity = existingItem.quantity + quantity;
if (available < newQuantity) {
throw new NotFoundException('Insufficient stock');
}
existingItem.quantity = newQuantity;
await manager.save(existingItem);
} else {
// Create new item
const cartItem = this.cartItemRepository.create({
cartId: cart.id,
productId,
quantity,
priceAtTime: product.getCurrentPrice(),
});
await manager.save(cartItem);
cart.items.push(cartItem);
}
// Reserve stock
await this.inventoryService.reserveStock(productId, quantity);
return await manager.findOne(Cart, {
where: { id: cart.id },
relations: ['items', 'items.product'],
});
});
}
async removeItem(userId: string, productId: string): Promise<Cart> {
return await this.dataSource.transaction(async (manager) => {
const cart = await this.getOrCreateCart(userId);
const item = cart.items.find((item) => item.productId === productId);
if (!item) {
throw new NotFoundException('Item not found in cart');
}
// Release reserved stock
await this.inventoryService.releaseStock(productId, item.quantity);
// Remove item
await manager.remove(item);
return await this.getOrCreateCart(userId);
});
}
async clearCart(userId: string): Promise<void> {
return await this.dataSource.transaction(async (manager) => {
const cart = await this.getOrCreateCart(userId);
// Release all reserved stock
for (const item of cart.items) {
await this.inventoryService.releaseStock(item.productId, item.quantity);
}
// Remove all items
await manager.remove(cart.items);
});
}
}
Orders Module
Orders are critical transactions. They must be atomic and consistent.
Order Entity
// src/modules/orders/entities/order.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { OrderItem } from './entities/order-item.entity';
import { Payment } from '../../payments/entities/payment.entity';
export enum OrderStatus {
PENDING = 'pending',
PROCESSING = 'processing',
CONFIRMED = 'confirmed',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
@Entity('orders')
@Index(['userId', 'createdAt'])
@Index(['status'])
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
orderNumber: string; // Human-readable order number (ORD-2026-001)
@ManyToOne(() => User)
user: User;
@Column()
userId: string;
@OneToMany(() => OrderItem, (item) => item.order, { cascade: true })
items: OrderItem[];
@Column('decimal', { precision: 10, scale: 2 })
subtotal: number;
@Column('decimal', { precision: 10, scale: 2, default: 0 })
tax: number;
@Column('decimal', { precision: 10, scale: 2, default: 0 })
shipping: number;
@Column('decimal', { precision: 10, scale: 2 })
total: number;
@Column({
type: 'enum',
enum: OrderStatus,
default: OrderStatus.PENDING,
})
status: OrderStatus;
@OneToMany(() => Payment, (payment) => payment.order)
payments: Payment[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// Domain methods
canBeCancelled(): boolean {
return [OrderStatus.PENDING, OrderStatus.PROCESSING].includes(this.status);
}
calculateTotal(): number {
return this.subtotal + this.tax + this.shipping;
}
}
Orders Service with Saga Pattern
// src/modules/orders/orders.service.ts
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Order, OrderStatus } from './entities/order.entity';
import { CartService } from '../cart/cart.service';
import { InventoryService } from '../inventory/inventory.service';
import { PaymentsService } from '../payments/payments.service';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class OrdersService {
constructor(
@InjectRepository(Order)
private readonly orderRepository: Repository<Order>,
private readonly cartService: CartService,
private readonly inventoryService: InventoryService,
private readonly paymentsService: PaymentsService,
private readonly notificationsService: NotificationsService,
private readonly dataSource: DataSource,
) {}
async createOrder(userId: string): Promise<Order> {
// Use Saga Pattern to handle distributed transactions
return await this.dataSource.transaction(async (manager) => {
// 1. Get cart
const cart = await this.cartService.getOrCreateCart(userId);
if (cart.isEmpty()) {
throw new BadRequestException('Cart is empty');
}
// 2. Validate inventory (final check)
for (const item of cart.items) {
const available = await this.inventoryService.getAvailableStock(
item.productId,
);
if (available < item.quantity) {
throw new BadRequestException(
`Insufficient stock for product ${item.productId}`,
);
}
}
// 3. Create order
const orderNumber = await this.generateOrderNumber();
const order = this.orderRepository.create({
orderNumber,
userId,
items: cart.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
price: item.priceAtTime,
})),
subtotal: cart.getTotal(),
tax: this.calculateTax(cart.getTotal()),
shipping: this.calculateShipping(cart),
status: OrderStatus.PENDING,
});
order.total = order.calculateTotal();
const savedOrder = await manager.save(order);
// 4. Commit stock (confirm reservation)
for (const item of cart.items) {
await this.inventoryService.commitStock(item.productId, item.quantity);
}
// 5. Clear cart
await this.cartService.clearCart(userId);
// 6. Process payment (async)
await this.paymentsService.processPayment(savedOrder.id, savedOrder.total);
// 7. Send notification (async)
await this.notificationsService.sendOrderConfirmation(
userId,
savedOrder.id,
);
return savedOrder;
});
}
private async generateOrderNumber(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.orderRepository.count({
where: {
createdAt: {
$gte: new Date(`${year}-01-01`),
$lt: new Date(`${year + 1}-01-01`),
} as any,
},
});
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private calculateTax(subtotal: number): number {
// Simplified: 10% tax
return subtotal * 0.1;
}
private calculateShipping(cart: any): number {
// Simplified: fixed shipping
return cart.getTotal() > 100 ? 0 : 10;
}
async cancelOrder(orderId: string, userId: string): Promise<Order> {
return await this.dataSource.transaction(async (manager) => {
const order = await manager.findOne(Order, {
where: { id: orderId, userId },
relations: ['items'],
});
if (!order) {
throw new NotFoundException('Order not found');
}
if (!order.canBeCancelled()) {
throw new BadRequestException('Order cannot be cancelled');
}
// Revert stock
for (const item of order.items) {
// Here you would need a method to revert stock
// For simplicity, we assume stock can be reverted
}
order.status = OrderStatus.CANCELLED;
return await manager.save(order);
});
}
}
Scalability Strategies
1. Redis Cache for Frequent Reads
Redis is ideal for e-commerce cache due to its speed and ability to share data across multiple application instances.
Redis Configuration in NestJS
// src/config/redis.config.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-store';
import { RedisClientOptions } from 'redis';
export const RedisCacheModule = CacheModule.register<RedisClientOptions>({
store: redisStore,
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
ttl: 300, // Default TTL: 5 minutes
});
// src/app.module.ts
import { Module } from '@nestjs/common';
import { RedisCacheModule } from './config/redis.config';
import { ProductsModule } from './modules/products/products.module';
@Module({
imports: [
RedisCacheModule,
ProductsModule,
// ... other modules
],
})
export class AppModule {}
Using Redis in the Products Service
// src/modules/products/products.service.ts (excerpt)
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, NotFoundException } from 'typeorm';
import { Product } from './entities/product.entity';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
async findOne(id: string): Promise<Product> {
const cacheKey = `product:${id}`;
// Try to get from Redis cache
const cached = await this.cacheManager.get<Product>(cacheKey);
if (cached) {
return cached;
}
// If not in cache, get from DB
const product = await this.productRepository.findOne({
where: { id },
relations: ['category'],
});
if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}
// Save to Redis with 5 minute TTL
await this.cacheManager.set(cacheKey, product, 300000);
return product;
}
async findAll(query: ProductQueryDto): Promise<{
data: Product[];
total: number;
page: number;
limit: number;
}> {
// Generate cache key based on search parameters
const cacheKey = `products:${JSON.stringify(query)}`;
const cached = await this.cacheManager.get<{
data: Product[];
total: number;
page: number;
limit: number;
}>(cacheKey);
if (cached) {
return cached;
}
// If not in cache, get from DB
const result = await this.getProductsFromDatabase(query);
// Save to Redis with shorter TTL for searches (2 minutes)
await this.cacheManager.set(cacheKey, result, 120000);
return result;
}
async update(id: string, updateProductDto: UpdateProductDto): Promise<Product> {
// Update in DB
const product = await this.updateProductInDatabase(id, updateProductDto);
// Invalidate cache for specific product
await this.cacheManager.del(`product:${id}`);
// Invalidate list caches (they may include this product)
const keys = await this.cacheManager.store.keys('products:*');
for (const key of keys) {
await this.cacheManager.del(key);
}
return product;
}
private async getProductsFromDatabase(query: ProductQueryDto) {
// DB search implementation
// ... (previous code)
}
private async updateProductInDatabase(id: string, dto: UpdateProductDto) {
// DB update implementation
// ... (previous code)
}
}
Inventory Cache with Redis
For inventory, we can use Redis to keep counters up to date:
// src/modules/inventory/inventory.service.ts (excerpt)
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class InventoryService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
// ... other dependencies
) {}
async getAvailableStock(productId: string): Promise<number> {
const cacheKey = `inventory:${productId}:available`;
// Try to get from Redis cache
const cached = await this.cacheManager.get<number>(cacheKey);
if (cached !== null && cached !== undefined) {
return cached;
}
// If not in cache, calculate from DB
const inventory = await this.inventoryRepository.findOne({
where: { productId },
});
const available = inventory ? inventory.getAvailable() : 0;
// Save to Redis with short TTL (30 seconds) because inventory changes frequently
await this.cacheManager.set(cacheKey, available, 30000);
return available;
}
async reserveStock(productId: string, quantity: number): Promise<Inventory> {
// Perform reservation in DB (transaction)
const inventory = await this.reserveStockInDatabase(productId, quantity);
// Update available inventory cache
const cacheKey = `inventory:${productId}:available`;
await this.cacheManager.set(cacheKey, inventory.getAvailable(), 30000);
return inventory;
}
}
Cache-Aside Pattern with Redis
// src/common/decorators/cache.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CACHE_TTL_KEY = 'cache_ttl';
export const CACHE_KEY_PREFIX = 'cache_key_prefix';
export const Cacheable = (ttl: number = 300, keyPrefix?: string) => {
return SetMetadata(CACHE_TTL_KEY, { ttl, keyPrefix });
};
// src/common/interceptors/redis-cache.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { Reflector } from '@nestjs/core';
import { CACHE_TTL_KEY } from '../decorators/cache.decorator';
@Injectable()
export class RedisCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private reflector: Reflector,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const cacheConfig = this.reflector.get(CACHE_TTL_KEY, context.getHandler());
if (!cacheConfig) {
return next.handle();
}
const { ttl, keyPrefix } = cacheConfig;
const cacheKey = keyPrefix
? `${keyPrefix}:${JSON.stringify(request.params)}`
: `cache:${request.url}`;
// Try to get from cache
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return of(cached);
}
// If not in cache, run handler and save result
return next.handle().pipe(
tap(async (data) => {
await this.cacheManager.set(cacheKey, data, ttl * 1000);
}),
);
}
}
Using the Interceptor
// src/modules/products/products.controller.ts (excerpt)
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { RedisCacheInterceptor } from '../../common/interceptors/redis-cache.interceptor';
import { Cacheable } from '../../common/decorators/cache.decorator';
@Controller('products')
@UseInterceptors(RedisCacheInterceptor)
export class ProductsController {
@Get(':id')
@Cacheable(300, 'product') // Cache for 5 minutes with prefix 'product'
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
}
2. Queue for Async Processing
// src/modules/orders/orders.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { OrdersService } from './orders.service';
@Processor('orders')
export class OrdersProcessor {
constructor(private readonly ordersService: OrdersService) {}
@Process('process-payment')
async handlePayment(job: Job) {
const { orderId, amount } = job.data;
// Process payment asynchronously
await this.ordersService.processPayment(orderId, amount);
}
@Process('send-notification')
async handleNotification(job: Job) {
const { userId, orderId } = job.data;
// Send notification asynchronously
await this.ordersService.sendNotification(userId, orderId);
}
}
3. Database Sharding for Scalability
// src/config/database.config.ts
export const databaseConfig = {
// Sharding configuration
shards: [
{
name: 'shard1',
host: process.env.DB_SHARD1_HOST,
port: parseInt(process.env.DB_SHARD1_PORT),
// ...
},
{
name: 'shard2',
host: process.env.DB_SHARD2_HOST,
port: parseInt(process.env.DB_SHARD2_PORT),
// ...
},
],
// Sharding function based on userId
shardKey: (userId: string) => {
// Distribute users across shards
const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return hash % 2 === 0 ? 'shard1' : 'shard2';
},
};
Security and Validation
DTOs with Validation
// src/modules/products/dto/create-product.dto.ts
import { IsString, IsNumber, IsOptional, Min, Max, IsUUID } from 'class-validator';
export class CreateProductDto {
@IsString()
sku: string;
@IsString()
name: string;
@IsString()
description: string;
@IsNumber()
@Min(0)
price: number;
@IsNumber()
@Min(0)
@IsOptional()
discountPrice?: number;
@IsNumber()
@Min(0)
stock: number;
@IsUUID()
categoryId: string;
}
Guards for Authorization
// src/common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
Monitoring and Observability
Logging Interceptor
// src/common/interceptors/logging.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const now = Date.now();
return next.handle().pipe(
tap({
next: () => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const responseTime = Date.now() - now;
this.logger.log(
`${method} ${url} ${statusCode} - ${responseTime}ms`,
);
},
error: (error) => {
const responseTime = Date.now() - now;
this.logger.error(
`${method} ${url} - ${responseTime}ms - ${error.message}`,
);
},
}),
);
}
}
My personal perspective
Designing a professional e-commerce requires balancing multiple concerns: performance, consistency, scalability, and security. NestJS has been an excellent choice for this type of project due to its modular architecture, which allows organizing code clearly and maintainably.
The key is understanding that not all parts of the system have the same requirements. The product catalog needs to be extremely fast for reads but can tolerate some latency on updates. Inventory, on the other hand, requires strict consistency to prevent overselling. Orders need to be transactional and atomic.
I’ve seen projects that failed because they didn’t consider these requirements from the start. Projects that optimized for performance but lost consistency. Projects that prioritized consistency but sacrificed performance.
The architecture presented here balances these requirements. It uses transactions where critical (inventory, orders), cache where performance matters (catalog), and async processing where latency can be tolerated (notifications, reports).
NestJS facilitates this architecture with its module system, dependency injection, and support for different persistence and communication strategies. Its structure forces you to think about separation of responsibilities from the start, resulting in more maintainable and testable code.
At the end of the day, a successful e-commerce isn’t just about having the right features. It’s about having an architecture that can evolve, scale, and be maintained. And that architecture begins with careful decisions about how to organize code, how to handle data, and how to balance the different requirements of the system.