Vendure en Producción: Fortalezas, Carencias y Cuándo Elegirlo
Una evaluación arquitectónica honesta de Vendure para comercio en producción. Sistema de plugins, EventBus, Worker service, AdminUI, despliegue multi-pod y comparación con Medusa y Saleor.
Por qué elegimos Vendure (y cuándo no lo haríamos)
Llevamos construyendo sobre Vendure desde sus primeras versiones, y realmente disfrutamos trabajar con él. La experiencia de desarrollo es excepcional. Escribes TypeScript en todas partes, la inyección de dependencias de NestJS mantiene los servicios limpios y testeables, el sistema de plugins te permite extender cualquier cosa sin hacer fork, y la API GraphQL está bien diseñada desde el primer día. Como equipo de arquitectos e ingenieros senior, nos importa mucho la DX, y Vendure la entrega mejor que cualquier otra plataforma de comercio con la que hayamos trabajado.
Esto no es marketing. Es lo que pasa cuando construyes dos plugins enterprise sobre un framework y todo funciona como esperas. Uno es un sistema completo de pipelines ETL (Vendure Data Hub Plugin) con 9 extractores, 61 operadores de transformación y 24 cargadores de entidades. El otro es una suite de customer intelligence con 6 módulos que cubre wishlists, reviews, programas de fidelización, recuperación de carritos, alertas de reposición y artículos vistos recientemente. Elegimos Vendure para estos porque la arquitectura de plugins nos dio la flexibilidad para construir sistemas complejos sin pelear contra el framework. El código se mantiene limpio, los patrones consistentes, y refactorizar es seguro porque TypeScript detecta todo en tiempo de compilación.
Dicho esto, Vendure no es perfecto para todos los casos. Este artículo es una evaluación honesta desde una perspectiva de arquitectura de sistemas. Si eres CTO evaluando plataformas de comercio, ingeniero líder planificando una construcción, o arquitecto diseñando un sistema multicanal, esto es lo que necesitas saber. Para más contexto sobre cómo abordamos las decisiones de plataformas ecommerce, esa guía cubre el panorama completo.
Visión general de la arquitectura
Vendure es un framework de comercio headless construido sobre NestJS (Node.js), TypeORM y GraphQL. El núcleo proporciona productos, pedidos, clientes, pagos, envíos, promociones y un sistema de plugins para extender todo.
┌─────────────────────────────────────────────────────────────┐
│ CLIENT APPLICATIONS │
│ Storefront (Next.js, Nuxt, etc.) │
│ Mobile App (React Native, Flutter) │
│ Admin Dashboard (React, built-in) │
│ POS System, Marketplace, Kiosk │
└────────────────────────┬────────────────────────────────────┘
│ GraphQL (Shop API + Admin API)
▼
┌─────────────────────────────────────────────────────────────┐
│ VENDURE SERVER │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Shop API │ │ Admin API │ │ Plugin APIs │ │
│ │ (customer) │ │ (backoffice)│ │ (extensions) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌──────▼─────────────────▼────────────────────▼─────────┐ │
│ │ NestJS Core │ │
│ │ Services │ Resolvers │ Guards │ Interceptors │ │
│ └──────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼────────────────────────────────┐ │
│ │ EventBus │ │
│ │ ProductEvent │ OrderEvent │ CustomerEvent │ Custom │ │
│ └──────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼────────────────────────────────┐ │
│ │ TypeORM + Database │ │
│ │ PostgreSQL (prod) / SQLite (dev) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Worker Service (BullMQ) │ │
│ │ Job Queues │ Email │ Search Index │ Custom Jobs │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Lo que Vendure hace brillantemente
1. El sistema de plugins
Este es el diferenciador más fuerte de Vendure. Un plugin puede extender cada capa del sistema: entidades, servicios, resolvers, esquema GraphQL, admin UI, workers y event handlers. Todo sin modificar el código fuente.
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { WishlistService } from './services/wishlist.service';
import { WishlistShopResolver } from './resolvers/wishlist-shop.resolver';
import { WishlistAdminResolver } from './resolvers/wishlist-admin.resolver';
import { CiWishlist } from './entities/wishlist.entity';
import { CiWishlistItem } from './entities/wishlist-item.entity';
import { wishlistShopSchema, wishlistAdminSchema } from './schemas';
@VendurePlugin({
imports: [PluginCommonModule],
entities: [CiWishlist, CiWishlistItem],
providers: [WishlistService],
shopApiExtensions: {
schema: wishlistShopSchema,
resolvers: [WishlistShopResolver],
},
adminApiExtensions: {
schema: wishlistAdminSchema,
resolvers: [WishlistAdminResolver],
},
configuration: (config) => {
// Modificar la configuración del core si es necesario
return config;
},
})
export class WishlistPlugin {}
Lo que lo hace genuinamente bueno:
- Extensión de entidades: Añade nuevas entidades TypeORM que obtienen sus propias tablas, migraciones y relaciones
- Extensión de esquema: Extiende los esquemas GraphQL de la Shop API y la Admin API de forma independiente
- Separación de resolvers: Los resolvers de la Shop API (cara al cliente) y los de la Admin API (backoffice) son siempre clases separadas. Esta es una restricción de diseño que previene la exposición accidental de operaciones de administración a los clientes
- Inyección de servicios: Inyección de dependencias completa de NestJS. Los servicios de tu plugin pueden inyectar servicios del core de Vendure.
- Custom fields: Añade campos a cualquier entidad del core (Product, Customer, Order) sin tocar el código fuente de Vendure
Construimos dos plugins enterprise usando este sistema. El Data Hub Plugin añade un motor completo de pipelines ETL con 9 extractores de datos, 61 operadores de transformación y 24 cargadores de entidades. El Customer Intelligence Plugin añade 6 módulos de engagement con sus propias entidades, APIs GraphQL, dashboards de administración y jobs en segundo plano. Ambos corren en producción sin modificar una sola línea del core de Vendure.
2. El EventBus
El EventBus de Vendure es la columna vertebral del acoplamiento débil entre módulos. Cada acción significativa del sistema emite un evento:
// Vendure core emite eventos automáticamente
ProductEvent // producto creado/actualizado/eliminado
OrderStateTransitionEvent // cambios de estado de pedido
CustomerEvent // cliente creado/actualizado
OrderPlacedEvent // pedido realizado con éxito
RefundStateTransitionEvent // cambios de estado de reembolso
// Tus plugins emiten eventos custom
export class CiWishlistItemAddedEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public wishlistId: string,
public productVariantId: string,
) {
super();
}
}
// Otros módulos se suscriben sin importar el módulo emisor
@Injectable()
export class StockSubscriber {
constructor(private eventBus: EventBus) {
this.eventBus.ofType(OrderPlacedEvent).subscribe(event => {
// Actualizar stock, notificar al almacén, etc.
});
}
}
La regla arquitectónica clave: los módulos se comunican solo a través del EventBus. Nada de importar servicios entre módulos. Esto previene dependencias circulares y mantiene los módulos desplegables de forma independiente. Cuando construimos el Customer Intelligence Plugin con 6 módulos (wishlist, reviews, loyalty, recuperación de carritos, back-in-stock, vistos recientemente), cada módulo solo conoce sus propios servicios. La coordinación entre módulos (por ejemplo, puntos de fidelización por una review) ocurre a través de eventos.
3. TypeScript de punta a punta (la DX que nos mantiene aquí)
Aquí es donde Vendure realmente brilla como experiencia de desarrollo. Todo el stack es TypeScript: servidor, plugins, admin UI, generación de código GraphQL. Todo está tipado, todo compila, todo detecta errores antes de la ejecución.
- Contratos de API type-safe desde la entidad de base de datos hasta el esquema GraphQL y el componente frontend
- El esquema GraphQL genera tipos TypeScript automáticamente con codegen
- Renombras un campo en tu entidad y el compilador te muestra cada lugar que necesita actualización
- Un solo lenguaje para servicios backend, desarrollo de plugins, dashboard de administración y suites de tests
- Los decoradores de NestJS (
@Injectable(),@Transaction(),@Allow()) hacen explícita la intención en el código RequestContextfluye a través de cada método de servicio, llevando contexto de autenticación, canal y locale
Esto no es menor. En plataformas de comercio basadas en PHP (Magento, Sylius, incluso la capa de comercio de Pimcore), pierdes la seguridad de tipos en cada frontera. En Vendure, un cambio breaking en tu entidad de producto aparece como un error de compilación en la query de tu storefront. Ese ciclo de retroalimentación es la diferencia entre pasar 2 minutos arreglando un type mismatch y pasar 2 horas depurando un bug en producción.
La flexibilidad también es notable. ¿Necesitas un flujo de checkout personalizado? Escribe un servicio. ¿Necesitas modificar cómo se aplican las promociones? Sobreescribe la estrategia de promoción. ¿Necesitas una entidad completamente nueva con su propia API GraphQL, página de dashboard de administración y job en segundo plano? El sistema de plugins maneja todo con patrones limpios que se mantienen consistentes a medida que el sistema crece. Nunca nos hemos sentido limitados por el framework, y eso es raro en plataformas de comercio.
4. Worker Service (BullMQ)
Vendure separa las operaciones de larga duración en un Worker service dedicado respaldado por BullMQ (Redis):
// Definir una cola de jobs en tu plugin
const myJobQueue = new JobQueue<{ productId: string }>({
name: 'generate-feed',
process: async (ctx, job) => {
const product = await this.productService.findOne(ctx, job.data.productId);
await this.feedGenerator.generate(product);
},
});
// Encolar desde cualquier lugar
await this.myJobQueue.add({ productId: '123' }, { ctx });
El Worker service corre como un proceso separado. En Kubernetes, es un deployment independiente con escalado propio. Esto separa limpiamente el manejo de requests (pods web) del procesamiento en segundo plano (pods worker).
Para ver cómo pensamos sobre patrones event-driven y arquitecturas de jobs en segundo plano, nuestra guía de ingeniería cubre los principios más amplios.
5. Diseño de la API GraphQL
Vendure proporciona dos APIs GraphQL separadas:
- Shop API: Operaciones cara al cliente (navegar productos, realizar pedidos, gestionar cuenta)
- Admin API: Operaciones de backoffice (gestionar productos, procesar pedidos, configurar el sistema)
Ambas soportan:
- Paginación automática con
ListQueryBuilder - Filtrado y ordenación integrados
- Permisos a nivel de campo
- Custom fields en cualquier entidad
// ListQueryBuilder genera SQL eficiente con paginación, ordenación y filtrado
async findByCustomer(ctx: RequestContext, options?: ListQueryOptions<CiWishlist>) {
return this.listQueryBuilder
.build(CiWishlist, options ?? {}, { ctx })
.andWhere('entity.customerId = :customerId', { customerId: ctx.activeUserId })
.getManyAndCount()
.then(([items, totalItems]) => ({ items, totalItems }));
}
6. La Admin UI con React (Vendure 3)
Vendure 3 viene con una admin UI completamente basada en React, diseñada para la extensibilidad desde cero. Reemplazó la antigua admin en Angular de Vendure 1/2 y es un gran paso adelante.
Los desarrolladores de plugins pueden:
- Añadir páginas y rutas personalizadas al dashboard de administración
- Extender vistas existentes con componentes custom
- Construir experiencias de dashboard completas usando React, TanStack Query y TanStack Router
- Usar los componentes del Dashboard SDK de Vendure (Page, PageTitle, DetailFormGrid, ListPage)
- Registrar elementos de menú, widgets y pestañas de detalle personalizados
Hemos construido nuestros dashboards de plugins usando este stack. La DX es sólida y se siente natural para cualquier desarrollador React.
7. Estrategias y puntos de extensión
Vendure proporciona interfaces de estrategia para personalizar el comportamiento del comercio sin modificar el código fuente:
// Condición de promoción personalizada
class MinimumOrderAmountCondition implements PromotionCondition {
code = 'minimum_order_amount';
description = [{ languageCode: LanguageCode.en, value: 'Minimum order amount' }];
args = {
amount: { type: 'int' },
};
check(ctx: RequestContext, order: Order, args: { amount: number }) {
return order.subTotal >= args.amount;
}
}
// Calculadora de envío personalizada
class WeightBasedShippingCalculator implements ShippingCalculator {
calculate(ctx: RequestContext, order: Order, args: any) {
const totalWeight = order.lines.reduce((sum, line) =>
sum + (line.productVariant.customFields.weight || 0) * line.quantity, 0
);
return { price: totalWeight * args.pricePerKg, priceIncludesTax: false };
}
}
Cálculo de impuestos, procesamiento de pagos, fulfillment de pedidos, almacenamiento de assets e indexación de búsqueda, todos tienen interfaces de estrategia. Esto significa que puedes cambiar cómo se calcula el IVA para un país específico, cómo se capturan los pagos o cómo se fulfillean los pedidos sin tocar el código del core. El framework proporciona los hooks, tú proporcionas la lógica de negocio.
8. Custom fields en entidades del core
Una de las funcionalidades más pragmáticas de Vendure. Añade campos a cualquier entidad del core sin migraciones ni modificaciones al core:
// En la configuración de tu plugin
customFields: {
Product: [
{ name: 'weight', type: 'float', defaultValue: 0, label: [{ languageCode: LanguageCode.en, value: 'Weight (kg)' }] },
{ name: 'erpId', type: 'string', unique: true, label: [{ languageCode: LanguageCode.en, value: 'ERP ID' }] },
{ name: 'countryOfOrigin', type: 'string', label: [{ languageCode: LanguageCode.en, value: 'Country of Origin' }] },
{ name: 'harmonizedCode', type: 'string', label: [{ languageCode: LanguageCode.en, value: 'HS Code' }] },
],
ProductVariant: [
{ name: 'supplierSku', type: 'string', label: [{ languageCode: LanguageCode.en, value: 'Supplier SKU' }] },
{ name: 'minOrderQty', type: 'int', defaultValue: 1, label: [{ languageCode: LanguageCode.en, value: 'Min Order Quantity' }] },
{ name: 'leadTimeDays', type: 'int', nullable: true, label: [{ languageCode: LanguageCode.en, value: 'Lead Time (days)' }] },
],
Customer: [
{ name: 'loyaltyTier', type: 'string', options: [{ value: 'bronze' }, { value: 'silver' }, { value: 'gold' }] },
{ name: 'erpCustomerId', type: 'string', unique: true, label: [{ languageCode: LanguageCode.en, value: 'ERP Customer ID' }] },
{ name: 'creditLimit', type: 'int', nullable: true, label: [{ languageCode: LanguageCode.en, value: 'Credit Limit (cents)' }] },
],
Order: [
{ name: 'erpOrderId', type: 'string', nullable: true, label: [{ languageCode: LanguageCode.en, value: 'ERP Order ID' }] },
{ name: 'exportedAt', type: 'datetime', nullable: true, label: [{ languageCode: LanguageCode.en, value: 'Exported to ERP' }] },
],
}
Los custom fields aparecen automáticamente en la admin UI, se incluyen en los esquemas GraphQL y son consultables a través de la API. Se almacenan en la misma tabla que la entidad del core, así que los joins son gratuitos. Puedes filtrar y ordenar por custom fields usando el ListQueryBuilder estándar de Vendure. Así es como conectas Vendure con sistemas externos: el erpId en Product se convierte en la clave de unión para la sincronización con el ERP, el erpCustomerId en Customer se enlaza con tu CRM, y el erpOrderId en Order rastrea qué pedidos han sido exportados.
Los custom fields también soportan relaciones con otras entidades, strings localizados (traducidos por idioma) y hooks de validación. Son el mecanismo principal para adaptar Vendure a tu dominio sin tablas de extensión separadas.
9. Estrategias de precios personalizadas
El sistema de precios de Vendure está basado en estrategias. El comportamiento por defecto calcula el precio a partir del precio almacenado de la variante de producto. Pero en el comercio enterprise, los precios rara vez son tan simples. Podrías necesitar precios de un ERP, descuentos específicos por cliente, niveles por volumen o cálculos de precio en tiempo real.
// Estrategia de precios personalizada que obtiene precios de un ERP externo
class ErpPricingStrategy implements OrderItemPriceCalculationStrategy {
private erpClient: ErpApiClient;
init(injector: Injector) {
this.erpClient = injector.get(ErpApiClient);
}
async calculateUnitPrice(
ctx: RequestContext,
productVariant: ProductVariant,
orderLineCustomFields: { [key: string]: any },
order: Order,
): Promise<PriceCalculationResult> {
// Verificar si la variante tiene un SKU del ERP
const erpSku = productVariant.customFields?.supplierSku;
if (!erpSku) {
// Fallback al precio almacenado en Vendure
return {
price: productVariant.listPrice,
priceIncludesTax: productVariant.listPriceIncludesTax,
};
}
// Obtener precio en tiempo real del ERP
const erpCustomerId = order.customer?.customFields?.erpCustomerId;
const erpPrice = await this.erpClient.getPrice({
sku: erpSku,
customerId: erpCustomerId,
quantity: 1,
currency: ctx.currencyCode,
});
return {
price: erpPrice.unitPriceCents,
priceIncludesTax: false,
};
}
}
Registra la estrategia en tu configuración de Vendure:
orderOptions: {
orderItemPriceCalculationStrategy: new ErpPricingStrategy(),
}
Así es como las instalaciones enterprise de Vendure manejan precios específicos por cliente, precios de contrato y cálculos de precio dinámicos. La estrategia recibe el contexto completo del pedido (cliente, cantidad, moneda) para que puedas implementar cualquier lógica de precios que tu negocio requiera. También puedes combinar esto con el sistema de promociones integrado de Vendure para apilar descuentos sobre precios base obtenidos del ERP.
10. Queries GraphQL personalizadas para integración con sistemas externos
Uno de los patrones más potentes de Vendure para integración enterprise: puedes añadir queries y mutations GraphQL personalizadas que obtienen datos de cualquier sistema externo y los exponen a través de la API de Vendure. Esto convierte a Vendure en una capa de API de comercio unificada que agrega datos de ERPs, PIMs, almacenes y otros backends.
// Extensión de esquema: añadir una query que obtiene stock en tiempo real de un ERP
const shopApiExtensions = gql`
type ErpStockInfo {
sku: String!
warehouseCode: String!
availableQty: Int!
nextDeliveryDate: DateTime
leadTimeDays: Int
}
type ErpPriceInfo {
sku: String!
unitPrice: Int!
currency: String!
priceListName: String
validUntil: DateTime
}
extend type Query {
erpStockAvailability(sku: String!): [ErpStockInfo!]!
erpCustomerPrice(sku: String!, quantity: Int): ErpPriceInfo
}
`;
// Resolver: obtener del API del ERP
@Resolver()
export class ErpIntegrationShopResolver {
constructor(private erpService: ErpIntegrationService) {}
@Query()
@Allow(Permission.Public)
async erpStockAvailability(
@Ctx() ctx: RequestContext,
@Args() args: { sku: string },
): Promise<ErpStockInfo[]> {
return this.erpService.getStockLevels(ctx, args.sku);
}
@Query()
@Allow(Permission.Owner)
async erpCustomerPrice(
@Ctx() ctx: RequestContext,
@Args() args: { sku: string; quantity?: number },
): Promise<ErpPriceInfo | null> {
const customerId = ctx.activeUser?.customFields?.erpCustomerId;
if (!customerId) return null;
return this.erpService.getCustomerPrice(ctx, customerId, args.sku, args.quantity ?? 1);
}
}
// Servicio: maneja las llamadas reales al API del ERP con caché y manejo de errores
@Injectable()
export class ErpIntegrationService {
constructor(
private httpService: HttpService,
private cacheService: CacheService,
) {}
async getStockLevels(ctx: RequestContext, sku: string): Promise<ErpStockInfo[]> {
const cacheKey = `erp:stock:${sku}`;
const cached = await this.cacheService.get(cacheKey);
if (cached) return cached;
const response = await this.httpService.get(
`${process.env.ERP_API_URL}/stock/${sku}`,
{ headers: { 'Authorization': `Bearer ${process.env.ERP_API_TOKEN}` } },
);
const result = response.data.warehouses.map((w: any) => ({
sku,
warehouseCode: w.code,
availableQty: w.available,
nextDeliveryDate: w.nextDelivery,
leadTimeDays: w.leadTime,
}));
await this.cacheService.set(cacheKey, result, { ttl: 300 }); // 5 min de caché
return result;
}
async getCustomerPrice(
ctx: RequestContext, customerId: string, sku: string, quantity: number,
): Promise<ErpPriceInfo | null> {
try {
const response = await this.httpService.post(
`${process.env.ERP_API_URL}/pricing`,
{ customerId, sku, quantity, currency: ctx.currencyCode },
);
return {
sku,
unitPrice: Math.round(response.data.unitPrice * 100), // convertir a centavos
currency: ctx.currencyCode,
priceListName: response.data.priceList,
validUntil: response.data.validUntil,
};
} catch (error) {
// ERP no disponible: devolver null, dejar que el frontend use el precio del catálogo
return null;
}
}
}
El storefront entonces consulta a Vendure tanto datos del catálogo como datos del ERP a través de un único endpoint GraphQL:
query ProductWithErpData($slug: String!, $sku: String!) {
product(slug: $slug) {
id
name
description
variants {
id
sku
price
customFields {
supplierSku
minOrderQty
leadTimeDays
}
}
}
erpStockAvailability(sku: $sku) {
warehouseCode
availableQty
nextDeliveryDate
}
erpCustomerPrice(sku: $sku, quantity: 1) {
unitPrice
priceListName
validUntil
}
}
Una sola petición. Datos del catálogo desde la base de datos de Vendure, stock del ERP, precios específicos por cliente del ERP. El storefront no necesita saber nada sobre el ERP. Solo consulta a Vendure. Este patrón funciona con cualquier sistema externo: APIs REST, servicios SOAP, endpoints GraphQL, backends gRPC. Vendure se convierte en la capa de composición.
Para ver cómo construimos estos pipelines de integración a escala (sincronizaciones programadas, listeners de webhooks, importaciones masivas), el Data Hub Plugin maneja todo el lado ETL. Las queries GraphQL personalizadas manejan el lado en tiempo real, por petición.
Dónde Vendure tiene carencias
1. Sin multi-almacén integrado
Vendure tiene un modelo de ubicación de stock única. Si necesitas inventario multi-almacén (stock en el almacén A, stock diferente en el almacén B, enrutamiento de fulfillment por ubicación), necesitas construirlo tú mismo o usar un plugin de terceros.
Esta es una carencia significativa para cualquier operación de comercio con más de una ubicación física. El workaround (custom fields + lógica de asignación de stock personalizada) es frágil y no se integra con el flujo de fulfillment de pedidos integrado de Vendure.
2. Funcionalidades B2B limitadas
Vendure está diseñado principalmente para comercio B2C. Requisitos B2B como:
- Cuentas de empresa con múltiples compradores
- Flujos de aprobación para pedidos
- Precios específicos por cliente con reglas complejas
- Gestión de cotizaciones
- Órdenes de compra
- Control de presupuesto por comprador
Todos requieren desarrollo personalizado. El sistema de Channels proporciona algo de capacidad multi-tenant, pero no es un conjunto de funcionalidades B2B.
3. La búsqueda es básica
La búsqueda integrada de Vendure usa un índice de texto completo respaldado por base de datos. Funciona para catálogos pequeños pero no escala para:
- Búsqueda facetada con filtros complejos
- Tolerancia a errores tipográficos
- Manejo de sinónimos
- Búsqueda multilingüe con analizadores específicos por idioma
- Actualizaciones de índice en tiempo real
Para búsqueda en producción, necesitas un motor externo (MeiliSearch, Elasticsearch, Algolia). Nuestro Data Hub Plugin incluye sinks de búsqueda para MeiliSearch, Elasticsearch, OpenSearch, Algolia y Typesense con indexación en tiempo real a través de eventos de Vendure. Para más sobre arquitectura de búsqueda en comercio, esa guía cubre los detalles técnicos.
4. Herramientas de migración
Las migraciones de TypeORM en Vendure pueden ser frágiles. Cuando múltiples plugins definen entidades, el orden de generación de migraciones se vuelve impredecible. Nos hemos encontrado con:
- Migraciones que referencian tablas aún no creadas por migraciones de otros plugins
- Discrepancias de tipos de columna entre SQLite (dev) y PostgreSQL (prod)
- Conflictos de migración cuando dos plugins modifican la misma entidad del core a través de custom fields
El workaround: generar migraciones por plugin, testear con PostgreSQL (no SQLite), y mantener un orden estricto de migraciones en tus scripts de despliegue. Manejamos desafíos de migración similares en nuestra guía de actualización de Pimcore, donde la gestión de esquemas de base de datos es aún más compleja.
Patrones de arquitectura de entidades
Después de construir dos plugins enterprise, estos patrones surgieron como innegociables para calidad de producción.
Convención de prefijos en entidades
Cada entidad de plugin usa un prefijo para prevenir colisiones de nombres:
// Nombres de tablas definidos en constantes (nunca hardcodeados)
export const TABLE_NAMES = {
WISHLIST: 'ci_wishlist',
WISHLIST_ITEM: 'ci_wishlist_item',
REVIEW: 'ci_review',
REVIEW_VOTE: 'ci_review_vote',
LOYALTY_ACCOUNT: 'ci_loyalty_account',
LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
// ...
};
// La entidad usa la constante
@Entity(TABLE_NAMES.WISHLIST)
@Index(['customerId', 'channelId'])
export class CiWishlist extends VendureEntity {
constructor(input?: DeepPartial<CiWishlist>) {
super(input);
}
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column()
customerId!: number;
@Column()
channelId!: number;
}
Cada entidad extiende VendureEntity (proporciona id, createdAt, updatedAt). Cada entidad incluye channelId para aislamiento multicanal. Cada nombre de tabla viene de una constante compartida, nunca strings hardcodeados.
Borrados lógicos
Para entidades que necesitan trazabilidad de auditoría, usa una columna deletedAt en lugar de borrados físicos:
@Column({ type: 'datetime', nullable: true })
deletedAt?: Date;
Esto preserva la integridad referencial y permite la recuperación. Filtra los registros borrados en todas las queries por defecto.
Alcance por canal
Cada query debe limitar su alcance al canal activo. El RequestContext de Vendure lleva la información del canal:
async findByCustomer(ctx: RequestContext, customerId: number) {
return this.connection.getRepository(ctx, CiWishlist).find({
where: {
customerId,
channelId: ctx.channelId,
},
});
}
Sin alcance por canal, los datos de un storefront se filtran hacia otro. Este es el bug de seguridad más común en despliegues multicanal de Vendure. Cubrimos patrones similares de aislamiento de datos multi-tenant en nuestra guía de gobernanza de IA y nuestra página de confianza.
Patrones de testing
Vendure soporta tanto tests unitarios como e2e para plugins. La configuración de testing es una de las fortalezas menos apreciadas del framework.
Tests unitarios
Testea métodos de servicio con repositorios mockeados:
import { describe, it, expect, vi } from 'vitest';
describe('WishlistService', () => {
it('should add item to wishlist', async () => {
const mockRepo = {
save: vi.fn().mockResolvedValue({ id: '1', productVariantId: '42' }),
findOne: vi.fn().mockResolvedValue(null),
};
const service = new WishlistService(mockRepo as any);
const result = await service.addItem(mockCtx, { productVariantId: '42' });
expect(result.productVariantId).toBe('42');
expect(mockRepo.save).toHaveBeenCalledOnce();
});
});
Tests E2E
Testea la API GraphQL completa con un servidor Vendure real:
import { createTestEnvironment } from '@vendure/testing';
import { testConfig } from './test-config';
describe('Wishlist Shop API', () => {
const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
beforeAll(async () => {
await server.init({ initialData, productsCsvPath: './test-data/products.csv' });
await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
});
afterAll(async () => {
await server.destroy();
});
it('should create a wishlist', async () => {
const { createWishlist } = await shopClient.query(CREATE_WISHLIST, {
input: { name: 'My Favorites' },
});
expect(createWishlist.name).toBe('My Favorites');
});
it('should enforce permissions', async () => {
await shopClient.asAnonymousUser();
const result = await shopClient.query(CREATE_WISHLIST, {
input: { name: 'Should Fail' },
});
expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
El createTestEnvironment de Vendure levanta un servidor real con SQLite, ejecuta migraciones, siembra datos de prueba y proporciona clientes GraphQL autenticados para ambas APIs (Shop y Admin). Los tests se ejecutan contra la superficie real de la API, no contra mocks. Esto detecta problemas de permisos, discrepancias de esquema y bugs de validación de datos que los tests unitarios no capturan.
Usamos Vitest con SWC para tests unitarios rápidos y Vitest con procesos forkeados para tests e2e (necesario porque el ciclo de vida del servidor de Vendure requiere aislamiento de procesos entre suites de tests). Para ver cómo abordamos el testing y la calidad en nuestra práctica de ingeniería más amplia, consulta nuestra página de metodología.
Coordinación multi-pod
Ejecutar Vendure en múltiples pods introduce desafíos de coordinación que no existen en despliegues de instancia única.
Elección de líder para schedulers
Los jobs programados (detección de carritos cada 15 minutos, expiración de loyalty diaria, verificaciones de bajadas de precio cada hora) deben ejecutarse en exactamente una instancia. Si todos los pods ejecutan el scheduler, obtienes procesamiento duplicado.
// Los servicios de scheduler usan elección de líder a través del JobQueue de Vendure
// Solo una instancia procesa el job
@Injectable()
export class CartDetectionService {
private jobQueue: JobQueue<{}>;
async onModuleInit() {
this.jobQueue = await this.jobQueueService.createQueue({
name: 'cart-detection',
process: async (ctx) => {
await this.detectAbandonedCarts(ctx);
},
});
// Programar: se ejecuta cada 15 minutos, pero solo en un pod
await this.jobQueue.add({}, { ctx: RequestContext.empty() });
}
}
Deduplicación por clave de negocio para consumidores de eventos
Los consumidores de eventos (notificaciones de bajada de precio, alertas de stock, solicitudes de review) corren en todos los pods. La suscripción al EventBus de cada pod recibe el evento. La deduplicación previene el envío de emails duplicados:
| Tipo de servicio | Comportamiento multi-instancia |
|---|---|
| Schedulers (detección de carritos, expiración de loyalty) | Elección de líder vía bloqueo en DB. Solo 1 instancia ejecuta. |
| Consumidores de eventos (bajadas de precio, alertas de stock) | Todas las instancias consumen. Idempotente vía deduplicación por clave de negocio. |
// Deduplicación por clave de negocio para notificaciones
const dedupeKey = `${recipientEmail}:${category}:${entityRef}:${dayBucket}`;
const existing = await this.notificationRepo.findOne({ where: { dedupeKey } });
if (existing) {
return; // Ya enviado hoy
}
La clave de deduplicación incluye un dayBucket (fecha UTC YYYY-MM-DD) para que la misma notificación pueda enviarse de nuevo al día siguiente, pero nunca dos veces el mismo día.
Idempotencia para mutations de API
Las mutations cara al cliente (añadir a wishlist, enviar review, canjear puntos de loyalty) necesitan idempotencia para manejar reintentos y fallos de red:
@Entity(TABLE_NAMES.IDEMPOTENCY_KEY)
export class CiIdempotencyKey extends VendureEntity {
@Column({ type: 'varchar', length: 255 })
@Index()
key!: string;
@Column({ type: 'varchar', length: 50 })
scope!: string; // 'wishlist_add', 'review_submit', etc.
@Column({ type: 'varchar', length: 64 })
requestHash!: string; // SHA-256 del input normalizado
@Column({ type: 'varchar', length: 20 })
status!: string; // PENDING, COMPLETED, FAILED
}
// Restricción única: UNIQUE(scope, key)
Dos modelos de idempotencia distintos:
- Idempotencia de API (CiIdempotencyKey): Para mutations iniciadas por el usuario. Clave proporcionada por el cliente o generada desde el hash del input. Reproduce la respuesta cacheada ante duplicados.
- Idempotencia de jobs (a nivel de cola): Para procesamiento en segundo plano. Clave de deduplicación en el payload del job. Verificada por el worker antes de la ejecución. Usa restricciones de DB o marcadores de completitud.
Nunca mezcles estos dos modelos. Tienen ciclos de vida diferentes y semánticas de fallo diferentes.
Para ver cómo manejamos patrones de concurrencia similares en nuestros sistemas, consulta nuestra guía sobre diseño de flujos de trabajo de IA que cubre desafíos de orquestación relacionados.
Data Hub: ETL para Vendure
Una de las mayores carencias del headless commerce es la integración de datos. ¿Cómo metes productos desde tu ERP en Vendure? ¿Cómo sincronizas inventario entre canales? ¿Cómo generas feeds de productos para Google Merchant Center?
Construimos el Vendure Data Hub Plugin para resolver esto. Es un motor completo de pipelines ETL que corre dentro de Vendure:
| Componente | Cantidad | Ejemplos |
|---|---|---|
| Extractores | 9 | HTTP/REST, GraphQL, Base de datos (SQL), Archivo (CSV/JSON/XML), S3, FTP/SFTP, Webhook, CDC |
| Operadores de transformación | 61 | String (12), Fecha (5), Numérico (9), Lógica (4), JSON (4), Datos (8), Enriquecimiento (5), Agregación (8), Validación (2) |
| Cargadores de entidades | 24 | Productos, Variantes, Clientes, Colecciones, Pedidos, Promociones, Assets, Facets |
| Generadores de feeds | 4 | Google Merchant Center, Meta Catalog, Amazon Seller Central, Custom |
| Sinks de búsqueda | 7 | Elasticsearch, OpenSearch, MeiliSearch, Algolia, Typesense, Productores de colas, Webhooks |
| Triggers | 6 | Manual, Programado (cron), Webhook, Eventos de Vendure, Observación de archivos, Cola de mensajes |
El plugin usa los mismos patrones que el core de Vendure: entidades TypeORM, servicios NestJS, API GraphQL, integración con EventBus. Incluye un editor visual de pipelines en el dashboard de administración, logs de ejecución en tiempo real, recuperación por checkpoint (reanudar desde el último registro exitoso) y bloqueos distribuidos para seguridad multi-pod.
Este es el tipo de infraestructura de ingeniería de datos que el comercio enterprise necesita pero rara vez obtiene de la propia plataforma de comercio.
Vendure vs Medusa vs Saleor (2026)
Una comparación honesta para arquitectos evaluando plataformas:
| Criterio | Vendure | Medusa v2 | Saleor |
|---|---|---|---|
| Lenguaje | TypeScript (NestJS) | TypeScript (framework propio) | Python (Django + GraphQL) |
| API | GraphQL (Shop + Admin) | REST + JS SDK + Admin API | GraphQL |
| Sistema de plugins | Excelente (entidades, esquema, resolvers, admin) | Bueno (módulos, workflows) | Limitado (apps vía webhooks) |
| Base de datos | PostgreSQL, MySQL, SQLite (TypeORM) | PostgreSQL (MikroORM) | PostgreSQL (Django ORM) |
| Admin UI | React (integrada, totalmente personalizable) | React (integrada) | React (integrada, Dashboard) |
| Worker/Jobs | BullMQ (integrado) | Sistema de jobs integrado | Celery (Python) |
| Multicanal | Channels (integrado) | Sales Channels | Channels |
| Funcionalidades B2B | Limitadas (requiere desarrollo custom) | En crecimiento (algunas integradas) | En crecimiento (algunas integradas) |
| Búsqueda | Básica (respaldada por DB) | Básica (necesita integración) | Básica (necesita integración) |
| Multi-almacén | No integrado | Integrado (v2) | Integrado |
| Comunidad | Media (en crecimiento) | Grande (crecimiento rápido) | Media |
| Hosting | Auto-alojado | Auto-alojado + Medusa Cloud | Saleor Cloud + auto-alojado |
| Madurez | Estable, probado en producción | v2 es más nuevo, posibles cambios de API | Estable, probado en producción |
| DX para equipos TypeScript | Excelente | Buena | Requiere conocimiento de Python |
| Plugins enterprise | Ecosistema fuerte | En crecimiento | Basado en webhooks (limitado) |
Cuándo elegir Vendure
- Tu equipo escribe TypeScript. La ventaja de DX no es marginal, es transformadora. La seguridad de tipos en todo el stack de comercio cambia la velocidad a la que puedes lanzar y la confianza con la que puedes refactorizar.
- Necesitas extensibilidad profunda de plugins. Ninguna otra plataforma te permite añadir entidades, extender el esquema GraphQL, construir páginas de dashboard de administración y registrar jobs en segundo plano desde una única declaración de plugin.
- Estás construyendo comercio B2C o híbrido con fuerte componente B2C con lógica de negocio personalizada. Vendure te da un core de comercio sólido y se quita de en medio para el resto.
- Quieres ser dueño de tu infraestructura. Vendure corre en cualquier lugar donde corra Node.js.
- Necesitas integración estrecha con microservicios NestJS existentes o backends TypeScript. Vendure ya es NestJS, así que tus servicios hablan el mismo lenguaje.
- Valoras la arquitectura limpia y la mantenibilidad a largo plazo. Los patrones (EventBus, RequestContext, resolvers separados Shop/Admin) escalan a grandes codebases sin degradarse.
Cuándo elegir Medusa
- Necesitas multi-almacén de fábrica.
- Prefieres REST sobre GraphQL para tu storefront.
- Quieres una opción cloud gestionada con la flexibilidad del open-source.
- Estás empezando de cero y quieres los patrones de framework más nuevos.
Cuándo elegir Saleor
- Tu equipo es Python/Django.
- Necesitas la oferta cloud gestionada (Saleor Cloud).
- Quieres internacionalización sólida integrada y multi-moneda.
- Necesitas funcionalidades de marketplace.
Cuándo elegir Shopify
- No quieres gestionar infraestructura.
- Tus necesidades de comercio son estándar (catálogo, checkout, pagos).
- Necesitas el ecosistema de apps más grande.
- Estás dispuesto a aceptar las restricciones de la plataforma a cambio de time-to-market más rápido.
Para nuestra perspectiva más amplia sobre plataformas de headless commerce y hacia dónde se dirige la industria, esa guía cubre el panorama completo.
Arquitectura de despliegue en producción
Stack recomendado
| Componente | Tecnología | Propósito |
|---|---|---|
| Servidor | Vendure (NestJS) | API de comercio |
| Base de datos | PostgreSQL 15+ | Almacén de datos primario |
| Caché | Redis 7+ | Sesiones, caché, colas de jobs |
| Búsqueda | MeiliSearch u OpenSearch | Búsqueda de productos, filtrado facetado |
| Almacenamiento | S3 / Azure Blob / local | Almacenamiento de assets |
| CDN | CloudFront / Cloudflare | Entrega de assets |
| Message broker | Redis (BullMQ) o RabbitMQ | Colas de jobs, procesamiento de eventos |
| Monitoreo | OpenTelemetry + Grafana | Observabilidad |
Arquitectura de pods en Kubernetes
| Pod | Propósito | Réplicas |
|---|---|---|
vendure-server | Shop API + Admin API | 2-4 |
vendure-worker | Procesamiento de jobs BullMQ | 1-3 |
postgres | Base de datos (o gestionada) | 1+ |
redis | Caché + colas | 1+ |
meilisearch | Motor de búsqueda | 1-2 |
Configuración de entorno
// vendure-config.ts (producción)
export const config: VendureConfig = {
apiOptions: {
port: 3000,
adminApiPath: 'admin-api',
shopApiPath: 'shop-api',
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || [],
},
},
dbConnectionOptions: {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
synchronize: false, // NUNCA true en producción
},
workerOptions: {
runInForkedProcess: false, // Deployment separado en K8s
},
jobQueueOptions: {
activeQueues: process.env.WORKER === 'true'
? undefined // Procesar todas las colas
: [], // No procesar ninguna (pod solo servidor)
},
plugins: [
AssetServerPlugin.init({
storageStrategyFactory: configureAssetStorage(),
}),
DefaultSearchPlugin.init({ bufferUpdates: true }),
EmailPlugin.init({ /* ... */ }),
// Tus plugins
DataHubPlugin,
CustomerIntelligencePlugin.init({ /* ... */ }),
],
};
La configuración crítica: jobQueueOptions.activeQueues. En los pods de servidor, configúralo como [] (array vacío) para que no procesen jobs en segundo plano. En los pods worker, déjalo como undefined para que procesen todas las colas. Esto separa el manejo de peticiones del procesamiento en segundo plano.
Para más sobre cómo pensamos sobre el despliegue cloud y la arquitectura de infraestructura, esa página de servicios cubre nuestro enfoque.
Consideraciones de rendimiento
Indexación de base de datos
Vendure crea índices básicos en las entidades del core. Para producción, añade índices en:
- Custom fields que consultas frecuentemente
- Columnas de entidades de plugin usadas en cláusulas WHERE
- Foreign keys en tablas grandes
- Índices compuestos para patrones de consulta comunes
@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId']) // Consulta común: reviews aprobadas para un producto
@Index(['customerId', 'createdAt']) // Consulta común: historial de reviews del cliente
export class CiReview extends VendureEntity {
// ...
}
Connection pooling
Para PostgreSQL en producción:
dbConnectionOptions: {
type: 'postgres',
extra: {
max: 20, // Máximo de conexiones por pod
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
},
}
Con 4 pods de servidor y 2 pods worker, son 120 conexiones en total. Asegúrate de que la configuración max_connections de tu PostgreSQL pueda manejar esto (el valor por defecto es 100, que es demasiado bajo).
Complejidad de queries GraphQL
Vendure no limita la profundidad ni la complejidad de queries GraphQL por defecto. En producción, añade límites para prevenir que queries costosas sobrecarguen la base de datos:
apiOptions: {
shopApiPlayground: false, // Desactivar en producción
adminApiPlayground: false,
middleware: [{
handler: depthLimit(10), // Profundidad máxima de query
route: '*',
}],
},
El futuro de Vendure
Vendure está evolucionando activamente. Áreas clave a seguir:
- Maduración del Dashboard: La admin UI con React ya es la opción por defecto. Espera más componentes integrados, mejores puntos de extensión para plugins y funcionalidades de dashboard más ricas de fábrica.
- Mejoras B2B: Cuentas de empresa y funcionalidades de comprador están en el roadmap.
- Mejor búsqueda: Puntos de integración de búsqueda más flexibles.
- Vendure Cloud: Opción de hosting gestionado (anunciado, aún no ampliamente disponible).
El equipo principal es receptivo y el proyecto tiene respaldo comercial (Vendure Ltd). El ecosistema TypeScript alrededor de él (NestJS, TypeORM, BullMQ) es maduro y está bien mantenido.
Errores comunes
-
Usar SQLite en producción. SQLite está bien para desarrollo. En producción, usa PostgreSQL. Las diferencias de comportamiento de TypeORM entre ambas bases de datos causarán bugs que nunca ves en desarrollo.
-
Ejecutar workers en el proceso del servidor. Separa tu despliegue de workers. Un job de larga duración en el mismo proceso que tu servidor de API bloqueará el manejo de peticiones.
-
Sin alcance por canal. Cada query debe filtrar por
ctx.channelId. Omitir esto filtra datos entre storefronts. -
Importaciones de servicios entre módulos. Usa el EventBus para comunicación entre módulos. Las importaciones directas crean dependencias circulares que rompen el sistema a medida que crece.
-
Sin idempotencia en mutations. Los reintentos de red y los replays de webhooks crearán pedidos duplicados, reviews duplicadas, items de wishlist duplicados. Construye idempotencia desde el primer día.
-
Nombres de tablas hardcodeados. Usa constantes. Cuando tienes más de 30 entidades repartidas en 3 plugins, los strings hardcodeados se convierten en una pesadilla de mantenimiento.
-
Synchronize: true en producción. El auto-sync de TypeORM eliminará columnas y perderá datos. Usa migraciones. Siempre.
-
No testear con PostgreSQL. Si desarrollas en SQLite y despliegas en PostgreSQL, descubrirás discrepancias de tipos de columna, diferencias de comportamiento de transacciones y bugs de manejo de JSON en producción.
-
Ignorar el Worker service. El envío de emails, la indexación de búsqueda y el procesamiento de assets deberían pasar por la cola de jobs. Hacerlos de forma síncrona en los handlers de peticiones hace tu API lenta y poco fiable.
-
Construir todo custom. Revisa el ecosistema de plugins primero. Para ETL/integración de datos, indexación de búsqueda y funcionalidades comunes de comercio, ya existen plugins. Construir desde cero toma 10 veces más tiempo.
Conclusiones clave
-
El sistema de plugins de Vendure es el mejor del headless commerce. Extensión de entidades, extensión de esquema, resolvers separados Shop/Admin, DI completo de NestJS. Ninguna otra plataforma se acerca para equipos TypeScript. La experiencia de desarrollo es lo que nos mantiene construyendo sobre él.
-
El EventBus permite un acoplamiento débil real. Los módulos se comunican solo a través de eventos. Esto escala a más de 6 módulos en un solo plugin sin dependencias circulares. Es el patrón que hace posibles los plugins enterprise.
-
El despliegue multi-pod requiere coordinación explícita. Elección de líder para schedulers, deduplicación por clave de negocio para consumidores de eventos, idempotencia para mutations de API. Nada de esto es automático.
-
La admin UI con React es totalmente personalizable. La admin basada en React de Vendure 3 soporta páginas, rutas, componentes y widgets de dashboard personalizados. Los desarrolladores de plugins obtienen una DX moderna con React 18, TanStack Query y el Dashboard SDK de Vendure.
-
Multi-almacén y B2B son carencias genuinas. Si estos son requisitos fundamentales, evalúa Medusa v2 o Saleor. Construirlos custom sobre Vendure es costoso.
-
Producción requiere PostgreSQL, Redis y despliegue de workers separado. SQLite es para desarrollo. BullMQ es para jobs en segundo plano. Separa tus pods de servidor y worker.
Realmente disfrutamos construyendo sobre Vendure. La experiencia de desarrollo, los patrones arquitectónicos y la flexibilidad del sistema de plugins lo convierten en una plataforma que recomendamos con confianza a equipos TypeScript que construyen comercio. El código se mantiene limpio incluso cuando crece, el framework no se interpone en tu camino, y la comunidad es receptiva y está creciendo. Si necesitas multi-almacén o B2B robusto de fábrica, evalúa alternativas. Para todo lo demás en headless commerce, Vendure es nuestra primera opción.
Explora nuestra guía de headless commerce con Vendure para más sobre nuestra práctica con Vendure, o mira casos de uso reales donde hemos desplegado Vendure en producción. Si estás evaluando plataformas de comercio para tu próximo proyecto, habla con nuestro equipo o solicita un presupuesto.
Temas cubiertos
Guías relacionadas
Plugins Vendure Empresariales: Patrones que sobreviven al despliegue Multi-Pod
Patrones de producción para el desarrollo de plugins Vendure. Convenciones de entidades, separación de resolvers, comunicación EventBus, elección de líder, deduplicación de notificaciones y testing con servidores reales.
Leer guíaGuía Empresarial de Sistemas de IA Agéntica
Guia tecnica de sistemas de IA agentica en entornos empresariales. Descubre la arquitectura, capacidades y aplicaciones de agentes IA autonomos.
Leer guíaComercio Agéntico: Cómo Dejar que los Agentes IA Compren de Forma Segura
Cómo diseñar comercio iniciado por agentes IA con gobernanza. Motores de políticas, puertas de aprobación HITL, recibos HMAC, idempotencia, aislamiento de tenants y el Agentic Checkout Protocol completo.
Leer guía¿Listo para construir sistemas de IA listos para producción?
Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.
Iniciar una conversación