Arquitectura 22 min de lectura

Arquitectura aplicada a un E-commerce

Publicado el 23 de diciembre de 2025

Arquitectura aplicada a un E-commerce

Diseñar un e-commerce profesional no es solo sobre crear una tienda online. Es sobre construir un sistema que pueda manejar miles de transacciones simultáneas, mantener la consistencia de datos críticos, escalar cuando el tráfico aumenta, y recuperarse de fallos sin perder información.

En este artículo, diseñaremos la arquitectura completa de un e-commerce desde cero. Usaremos NestJS como framework, ya que es extremadamente robusto, bien estructurado, y proporciona las herramientas necesarias para construir aplicaciones empresariales escalables. NestJS ha sido una excelente elección para proyectos complejos debido a su arquitectura modular, inyección de dependencias nativa, y soporte para microservicios.

Cubriremos: análisis de requisitos, diseño de la arquitectura, manejo de inventario, procesamiento de transacciones, y estrategias de escalabilidad. Todo con ejemplos de código prácticos y explicaciones detalladas de cada decisión arquitectónica.

Análisis de Requisitos

Antes de escribir una línea de código, necesitamos entender qué estamos construyendo.

Requisitos Funcionales

  1. Gestión de Productos

    • Catálogo de productos con categorías
    • Búsqueda y filtrado
    • Gestión de inventario (stock)
    • Precios y descuentos
  2. Gestión de Usuarios

    • Registro y autenticación
    • Perfiles de usuario
    • Historial de pedidos
  3. Carrito de Compras

    • Agregar/eliminar productos
    • Calcular totales
    • Persistencia de carrito
  4. Procesamiento de Pedidos

    • Checkout
    • Validación de inventario
    • Procesamiento de pagos
    • Confirmación de pedidos
  5. Gestión de Inventario

    • Actualización en tiempo real
    • Prevención de sobreventa
    • Alertas de stock bajo

Requisitos No Funcionales

  1. Rendimiento

    • Respuesta < 200ms para 95% de requests
    • Soporte para 10,000 usuarios concurrentes
  2. Disponibilidad

    • 99.9% uptime
    • Recuperación automática de fallos
  3. Escalabilidad

    • Escalar horizontalmente
    • Manejar picos de tráfico (Black Friday, etc.)
  4. Seguridad

    • Protección de datos de pago
    • Autenticación segura
    • Prevención de fraudes
  5. Consistencia

    • No vender productos sin stock
    • Transacciones ACID para pagos

Arquitectura General

Diseñaremos una arquitectura modular basada en microservicios, pero comenzando con una estructura monolítica modular que puede evolucionar.

Estructura de Módulos en NestJS

src/
├── modules/
│   ├── products/          # Gestión de productos
│   ├── users/             # Gestión de usuarios
│   ├── cart/              # Carrito de compras
│   ├── orders/            # Procesamiento de pedidos
│   ├── inventory/         # Gestión de inventario
│   ├── payments/          # Procesamiento de pagos
│   └── notifications/     # Notificaciones
├── common/                # Utilidades compartidas
│   ├── decorators/
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   └── pipes/
├── config/                # Configuración
└── database/              # Configuración de BD

Principios Arquitectónicos

  1. Separación de Responsabilidades: Cada módulo tiene una responsabilidad única
  2. Inversión de Dependencias: Dependemos de abstracciones, no de implementaciones
  3. Domain-Driven Design: El dominio del negocio guía la estructura
  4. CQRS: Separación de comandos (escritura) y queries (lectura) donde tenga sentido

Módulo de Productos

El módulo de productos es el corazón del catálogo. Debe ser rápido para lecturas y consistente para escrituras.

Estructura del Módulo

// 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], // Exportar para uso en otros módulos
})
export class ProductsModule {}

Entidad de Producto

// 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']) // Índice compuesto para búsqueda
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;

  // Métodos de dominio
  isInStock(): boolean {
    return this.stock > 0;
  }

  getCurrentPrice(): number {
    return this.discountPrice ?? this.price;
  }

  canPurchase(quantity: number): boolean {
    return this.isActive && this.stock >= quantity;
  }
}

Servicio de Productos

// 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: marcar como inactivo en lugar de eliminar
    product.isActive = false;
    await this.productRepository.save(product);
  }
}

Controlador de Productos

// 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);
  }
}

Módulo de Inventario

El inventario es crítico. Debemos prevenir sobreventa y mantener consistencia.

Entidad de Inventario

// 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; // Cantidad reservada en carritos

  @Column({ default: 10 })
  lowStockThreshold: number;

  @VersionColumn() // Optimistic locking
  version: number;

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  lastUpdated: Date;

  // Métodos de dominio
  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();
  }
}

Servicio de Inventario con 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> {
    // Usar transacción para asegurar consistencia
    return await this.dataSource.transaction(async (manager) => {
      const inventory = await manager.findOne(Inventory, {
        where: { productId },
        lock: { mode: 'pessimistic_write' }, // Lock pesimista para operaciones críticas
      });

      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();
  }
}

Módulo de Carrito

El carrito debe ser rápido y permitir persistencia.

Entidad de Carrito

// 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;

  // Métodos de dominio
  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;
  }
}

Entidad de Item de Carrito

// 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; // Precio al momento de agregar al carrito

  // Métodos de dominio
  getSubtotal(): number {
    return this.priceAtTime * this.quantity;
  }
}

Servicio de Carrito

// 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) => {
      // Verificar disponibilidad de inventario
      const available = await this.inventoryService.getAvailableStock(productId);
      if (available < quantity) {
        throw new NotFoundException('Insufficient stock');
      }

      // Obtener producto
      const product = await this.productsService.findOne(productId);
      if (!product.isActive) {
        throw new NotFoundException('Product is not available');
      }

      // Obtener o crear carrito
      const cart = await this.getOrCreateCart(userId);

      // Verificar si el producto ya está en el carrito
      const existingItem = cart.items.find((item) => item.productId === productId);

      if (existingItem) {
        // Actualizar cantidad
        const newQuantity = existingItem.quantity + quantity;
        if (available < newQuantity) {
          throw new NotFoundException('Insufficient stock');
        }
        existingItem.quantity = newQuantity;
        await manager.save(existingItem);
      } else {
        // Crear nuevo item
        const cartItem = this.cartItemRepository.create({
          cartId: cart.id,
          productId,
          quantity,
          priceAtTime: product.getCurrentPrice(),
        });
        await manager.save(cartItem);
        cart.items.push(cartItem);
      }

      // Reservar 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');
      }

      // Liberar stock reservado
      await this.inventoryService.releaseStock(productId, item.quantity);

      // Eliminar 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);

      // Liberar todo el stock reservado
      for (const item of cart.items) {
        await this.inventoryService.releaseStock(item.productId, item.quantity);
      }

      // Eliminar todos los items
      await manager.remove(cart.items);
    });
  }
}

Módulo de Pedidos

Los pedidos son transacciones críticas. Deben ser atómicos y consistentes.

Entidad de Pedido

// 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; // Número de pedido legible (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;

  // Métodos de dominio
  canBeCancelled(): boolean {
    return [OrderStatus.PENDING, OrderStatus.PROCESSING].includes(this.status);
  }

  calculateTotal(): number {
    return this.subtotal + this.tax + this.shipping;
  }
}

Servicio de Pedidos con 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> {
    // Usar Saga Pattern para manejar transacciones distribuidas
    return await this.dataSource.transaction(async (manager) => {
      // 1. Obtener carrito
      const cart = await this.cartService.getOrCreateCart(userId);
      if (cart.isEmpty()) {
        throw new BadRequestException('Cart is empty');
      }

      // 2. Validar inventario (última verificación)
      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. Crear pedido
      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 (confirmar reserva)
      for (const item of cart.items) {
        await this.inventoryService.commitStock(item.productId, item.quantity);
      }

      // 5. Limpiar carrito
      await this.cartService.clearCart(userId);

      // 6. Procesar pago (asíncrono)
      await this.paymentsService.processPayment(savedOrder.id, savedOrder.total);

      // 7. Enviar notificación (asíncrono)
      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 {
    // Simplificado: 10% de impuesto
    return subtotal * 0.1;
  }

  private calculateShipping(cart: any): number {
    // Simplificado: shipping fijo
    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');
      }

      // Revertir stock
      for (const item of order.items) {
        // Aquí necesitarías un método para revertir stock
        // Por simplicidad, asumimos que el stock se puede revertir
      }

      order.status = OrderStatus.CANCELLED;
      return await manager.save(order);
    });
  }
}

Estrategias de Escalabilidad

1. Caché con Redis para Lecturas Frecuentes

Redis es ideal para caché en e-commerce debido a su velocidad y capacidad de compartir datos entre múltiples instancias de la aplicación.

Configuración de Redis en 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, // TTL por defecto: 5 minutos
});
// 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,
    // ... otros módulos
  ],
})
export class AppModule {}

Uso de Redis en el Servicio de Productos

// src/modules/products/products.service.ts (extracto)
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}`;
    
    // Intentar obtener del caché Redis
    const cached = await this.cacheManager.get<Product>(cacheKey);
    if (cached) {
      return cached;
    }

    // Si no está en caché, obtener de BD
    const product = await this.productRepository.findOne({
      where: { id },
      relations: ['category'],
    });

    if (!product) {
      throw new NotFoundException(`Product with ID ${id} not found`);
    }

    // Guardar en Redis con TTL de 5 minutos
    await this.cacheManager.set(cacheKey, product, 300000);

    return product;
  }

  async findAll(query: ProductQueryDto): Promise<{
    data: Product[];
    total: number;
    page: number;
    limit: number;
  }> {
    // Generar clave de caché basada en los parámetros de búsqueda
    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;
    }

    // Si no está en caché, obtener de BD
    const result = await this.getProductsFromDatabase(query);

    // Guardar en Redis con TTL más corto para búsquedas (2 minutos)
    await this.cacheManager.set(cacheKey, result, 120000);

    return result;
  }

  async update(id: string, updateProductDto: UpdateProductDto): Promise<Product> {
    // Actualizar en BD
    const product = await this.updateProductInDatabase(id, updateProductDto);

    // Invalidar caché del producto específico
    await this.cacheManager.del(`product:${id}`);
    
    // Invalidar caché de listados (pueden incluir este producto)
    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) {
    // Implementación de búsqueda en BD
    // ... (código anterior)
  }

  private async updateProductInDatabase(id: string, dto: UpdateProductDto) {
    // Implementación de actualización en BD
    // ... (código anterior)
  }
}

Caché de Inventario con Redis

Para el inventario, podemos usar Redis para mantener contadores actualizados:

// src/modules/inventory/inventory.service.ts (extracto)
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,
    // ... otras dependencias
  ) {}

  async getAvailableStock(productId: string): Promise<number> {
    const cacheKey = `inventory:${productId}:available`;
    
    // Intentar obtener del caché Redis
    const cached = await this.cacheManager.get<number>(cacheKey);
    if (cached !== null && cached !== undefined) {
      return cached;
    }

    // Si no está en caché, calcular desde BD
    const inventory = await this.inventoryRepository.findOne({
      where: { productId },
    });

    const available = inventory ? inventory.getAvailable() : 0;

    // Guardar en Redis con TTL corto (30 segundos) porque el inventario cambia frecuentemente
    await this.cacheManager.set(cacheKey, available, 30000);

    return available;
  }

  async reserveStock(productId: string, quantity: number): Promise<Inventory> {
    // Realizar reserva en BD (transacción)
    const inventory = await this.reserveStockInDatabase(productId, quantity);

    // Actualizar caché de inventario disponible
    const cacheKey = `inventory:${productId}:available`;
    await this.cacheManager.set(cacheKey, inventory.getAvailable(), 30000);

    return inventory;
  }
}

Patrón Cache-Aside con 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}`;

    // Intentar obtener del caché
    const cached = await this.cacheManager.get(cacheKey);
    if (cached) {
      return of(cached);
    }

    // Si no está en caché, ejecutar handler y guardar resultado
    return next.handle().pipe(
      tap(async (data) => {
        await this.cacheManager.set(cacheKey, data, ttl * 1000);
      }),
    );
  }
}

Uso del Interceptor

// src/modules/products/products.controller.ts (extracto)
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') // Cachear por 5 minutos con prefijo 'product'
  findOne(@Param('id') id: string) {
    return this.productsService.findOne(id);
  }
}

2. Queue para Procesamiento Asíncrono

// 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;
    // Procesar pago de manera asíncrona
    await this.ordersService.processPayment(orderId, amount);
  }

  @Process('send-notification')
  async handleNotification(job: Job) {
    const { userId, orderId } = job.data;
    // Enviar notificación de manera asíncrona
    await this.ordersService.sendNotification(userId, orderId);
  }
}

3. Database Sharding para Escalabilidad

// src/config/database.config.ts
export const databaseConfig = {
  // Configuración para sharding
  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),
      // ...
    },
  ],
  // Función de sharding basada en userId
  shardKey: (userId: string) => {
    // Distribuir usuarios entre shards
    const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
    return hash % 2 === 0 ? 'shard1' : 'shard2';
  },
};

Seguridad y Validación

DTOs con Validación

// 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 para Autorización

// 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));
  }
}

Monitoreo y Observabilidad

Interceptor para Logging

// 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}`,
          );
        },
      }),
    );
  }
}

Mi perspectiva personal

Diseñar un e-commerce profesional requiere equilibrar múltiples preocupaciones: rendimiento, consistencia, escalabilidad y seguridad. NestJS ha sido una excelente elección para este tipo de proyectos debido a su arquitectura modular, que permite organizar el código de manera clara y mantenible.

La clave está en entender que no todas las partes del sistema tienen los mismos requisitos. El catálogo de productos necesita ser extremadamente rápido para lecturas, pero puede tolerar cierta latencia en actualizaciones. El inventario, por otro lado, requiere consistencia estricta para prevenir sobreventa. Los pedidos necesitan ser transaccionales y atómicos.

He visto proyectos que fallaron porque no consideraron estos requisitos desde el inicio. Proyectos que optimizaron para rendimiento pero perdieron consistencia. Proyectos que priorizaron consistencia pero sacrificaron rendimiento.

La arquitectura que he presentado aquí balancea estos requisitos. Usa transacciones donde es crítico (inventario, pedidos), caché donde el rendimiento importa (catálogo), y procesamiento asíncrono donde la latencia puede tolerarse (notificaciones, reportes).

NestJS facilita esta arquitectura con su sistema de módulos, inyección de dependencias, y soporte para diferentes estrategias de persistencia y comunicación. Su estructura te fuerza a pensar en la separación de responsabilidades desde el inicio, lo que resulta en código más mantenible y testeable.

Al final del día, un e-commerce exitoso no es solo sobre tener las features correctas. Es sobre tener una arquitectura que pueda evolucionar, escalar, y mantenerse. Y esa arquitectura comienza con decisiones cuidadosas sobre cómo organizar el código, cómo manejar los datos, y cómo balancear los diferentes requisitos del sistema.