Architecture 21 min read

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

  1. Product Management

    • Product catalog with categories
    • Search and filtering
    • Inventory (stock) management
    • Prices and discounts
  2. User Management

    • Registration and authentication
    • User profiles
    • Order history
  3. Shopping Cart

    • Add/remove products
    • Calculate totals
    • Cart persistence
  4. Order Processing

    • Checkout
    • Inventory validation
    • Payment processing
    • Order confirmation
  5. Inventory Management

    • Real-time updates
    • Overselling prevention
    • Low stock alerts

Non-Functional Requirements

  1. Performance

    • Response < 200ms for 95% of requests
    • Support for 10,000 concurrent users
  2. Availability

    • 99.9% uptime
    • Automatic failure recovery
  3. Scalability

    • Horizontal scaling
    • Handle traffic spikes (Black Friday, etc.)
  4. Security

    • Payment data protection
    • Secure authentication
    • Fraud prevention
  5. 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

  1. Separation of Responsibilities: Each module has a single responsibility
  2. Dependency Inversion: We depend on abstractions, not implementations
  3. Domain-Driven Design: Business domain guides the structure
  4. 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.