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.
Más allá del tutorial de plugins
Cada tutorial de Vendure te muestra cómo crear un plugin con una entidad, un resolver y un servicio. Eso te lleva de cero a "funciona en mi portátil". No te lleva a producción con múltiples pods, workers en segundo plano, deduplicación de emails y más de 30 entidades repartidas en 6 módulos que se comunican sin dependencias circulares.
Hemos construido dos plugins empresariales para Vendure. El Data Hub Plugin es un motor de pipelines ETL con 9 extractores, 61 operadores de transformación y 24 cargadores de entidades. El Customer Intelligence Plugin cubre listas de deseos, reseñas, programas de fidelización, recuperación de carritos, alertas de vuelta en stock y artículos vistos recientemente. Entre ambos, tenemos más de 50 entidades, docenas de resolvers, múltiples colas de trabajo y dashboards construidos con React y TanStack.
Este artículo cubre los patrones que sobrevivieron a producción. Para la evaluación arquitectónica a nivel de plataforma, consulta nuestra guía de arquitectura Vendure en producción. Este artículo profundiza en los patrones de implementación de plugins.
Convenciones de entidades
Constantes para nombres de tablas
Nunca escribas nombres de tablas directamente en el código. Con más de 30 entidades repartidas en múltiples plugins, las cadenas escritas a mano se convierten en un desastre de mantenimiento.
// shared/constants/index.ts
export const TABLE_NAMES = {
// Módulo de listas de deseos
WISHLIST: 'ci_wishlist',
WISHLIST_ITEM: 'ci_wishlist_item',
// Módulo de reseñas
REVIEW: 'ci_review',
REVIEW_VOTE: 'ci_review_vote',
REVIEW_RESPONSE: 'ci_review_response',
// Módulo de fidelización
LOYALTY_ACCOUNT: 'ci_loyalty_account',
LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
LOYALTY_TIER: 'ci_loyalty_tier',
// Módulo de recuperación de carritos
ABANDONED_CART: 'ci_abandoned_cart',
RECOVERY_FLOW: 'ci_recovery_flow',
// Infraestructura
IDEMPOTENCY_KEY: 'ci_idempotency_key',
NOTIFICATION_LOG: 'ci_notification_log',
AUDIT_ENTRY: 'ci_audit_entry',
} as const;
El prefijo ci_ previene colisiones con las tablas del core de Vendure y otros plugins. Cada entidad usa la constante, nunca una cadena literal.
Patrón base de entidades
Cada entidad sigue la misma estructura:
import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { DeepPartial, VendureEntity } from '@vendure/core';
import { TABLE_NAMES } from '../../shared/constants';
@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId'])
@Index(['customerId', 'createdAt'])
export class CiReview extends VendureEntity {
constructor(input?: DeepPartial<CiReview>) {
super(input);
}
@Column()
productId!: number;
@Column()
customerId!: number;
@Column()
channelId!: number;
@Column({ type: 'int', default: 0 })
rating!: number;
@Column({ type: 'text' })
body!: string;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status!: string; // pending | approved | rejected
@Column({ type: 'datetime', nullable: true })
deletedAt?: Date;
}
Reglas:
- Cada entidad extiende
VendureEntity(proporcionaid,createdAt,updatedAt) - Cada entidad usa
TABLE_NAMES.Xpara el nombre de la tabla - Cada entidad incluye
channelIdpara aislamiento multi-canal - Los nombres de clase usan el prefijo
Ci:CiReview,CiWishlist,CiLoyaltyAccount - El constructor acepta
DeepPartial<T>para instanciación limpia - Los índices se definen en la entidad, no en las migraciones, para mayor visibilidad
Eliminación suave (Soft Deletes)
Para entidades que necesitan trazas de auditoría o recuperación, añade deletedAt en lugar de eliminar registros. Filtra en todas las consultas:
async findActive(ctx: RequestContext, options?: ListQueryOptions<CiReview>) {
return this.listQueryBuilder
.build(CiReview, options ?? {}, { ctx })
.andWhere('entity.deletedAt IS NULL')
.andWhere('entity.channelId = :channelId', { channelId: ctx.channelId })
.getManyAndCount()
.then(([items, totalItems]) => ({ items, totalItems }));
}
Separación de resolvers
Los resolvers del Shop API (cara al cliente) y del Admin API (backoffice) siempre son clases separadas. Esto no es una sugerencia. Es una restricción de diseño que previene la exposición accidental de operaciones administrativas a los clientes.
// resolvers/review-shop.resolver.ts
@Resolver()
export class ReviewShopResolver {
constructor(private reviewService: ReviewService) {}
@Query()
@Allow(Permission.Public)
async productReviews(
@Ctx() ctx: RequestContext,
@Args() args: { productId: string; options?: ListQueryOptions<CiReview> },
) {
return this.reviewService.findApprovedByProduct(ctx, args.productId, args.options);
}
@Mutation()
@Transaction()
@Allow(Permission.Owner)
async submitReview(
@Ctx() ctx: RequestContext,
@Args() args: { input: SubmitReviewInput },
) {
return this.reviewService.submit(ctx, args.input);
}
}
// resolvers/review-admin.resolver.ts
@Resolver()
export class ReviewAdminResolver {
constructor(private reviewService: ReviewService) {}
@Query()
@Allow(Permission.ReadCatalog)
async allReviews(
@Ctx() ctx: RequestContext,
@Args() args: { options?: ListQueryOptions<CiReview> },
) {
return this.reviewService.findAll(ctx, args.options); // incluye pendientes y rechazadas
}
@Mutation()
@Transaction()
@Allow(Permission.UpdateCatalog)
async moderateReview(
@Ctx() ctx: RequestContext,
@Args() args: { reviewId: string; decision: 'approve' | 'reject'; reason?: string },
) {
return this.reviewService.moderate(ctx, args.reviewId, args.decision, args.reason);
}
}
El resolver de Shop expone productReviews (solo aprobadas, acceso público). El resolver de Admin expone allReviews (todos los estados, acceso administrativo) y moderateReview (solo admin). Nunca comparten una clase.
Comunicación por EventBus
Los módulos se comunican exclusivamente a través del EventBus. El módulo de listas de deseos no importa el servicio del módulo de fidelización. El módulo de reseñas no importa el servicio del módulo de notificaciones.
// El módulo A emite un evento
@Injectable()
export class ReviewService {
constructor(private eventBus: EventBus) {}
async submit(ctx: RequestContext, input: SubmitReviewInput): Promise<CiReview> {
const review = await this.createReview(ctx, input);
// Emite el evento, no llames al servicio de fidelización directamente
await this.eventBus.publish(new CiReviewSubmittedEvent(ctx, review));
return review;
}
}
// El módulo B se suscribe y procesa
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
this.eventBus.ofType(CiReviewSubmittedEvent).subscribe(async event => {
// Otorga puntos de fidelización por enviar una reseña
await this.loyaltyService.awardPoints(
event.ctx,
event.review.customerId,
'review_submitted',
50, // puntos
);
});
}
}
Esto mantiene los límites entre módulos limpios. Puedes eliminar el módulo de fidelización sin romper el módulo de reseñas. El evento se publica independientemente de si hay suscriptores o no.
Para ver cómo aplicamos patrones de EventBus en sistemas fuera de Vendure, consulta nuestra guía de arquitectura orientada a eventos.
Coordinación Multi-Pod
Elección de líder para schedulers
Los trabajos programados (detectar carritos abandonados, expirar puntos de fidelización, comprobar bajadas de precio) deben ejecutarse en exactamente un pod. Sin elección de líder, cada pod ejecuta el scheduler y obtienes procesamiento duplicado.
El JobQueue de Vendure se encarga de esto. Solo un worker recoge un trabajo de la cola. Programa los trabajos a través de la cola, no con setInterval:
@Injectable()
export class CartDetectionService implements OnModuleInit {
private detectionQueue: JobQueue<Record<string, never>>;
constructor(private jobQueueService: JobQueueService) {}
async onModuleInit() {
this.detectionQueue = await this.jobQueueService.createQueue({
name: 'ci-cart-detection',
process: async (ctx) => {
await this.detectAbandonedCarts(ctx);
},
});
}
// Llamado por un scheduler o trigger cron
async scheduleDetection(ctx: RequestContext) {
await this.detectionQueue.add({}, { ctx });
}
}
Deduplicación de notificaciones
Cuando todos los pods consumen el mismo evento, cada pod podría intentar enviar la misma notificación. El almacén de deduplicación previene duplicados:
@Injectable()
export class NotificationService {
async sendIfNotDuplicate(
ctx: RequestContext,
recipient: string,
category: string,
entityRef: string,
): Promise<boolean> {
const dayBucket = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const dedupeKey = `${recipient.toLowerCase()}:${category}:${entityRef}:${dayBucket}`;
const existing = await this.connection
.getRepository(ctx, CiNotificationLog)
.findOne({ where: { dedupeKey } });
if (existing) return false; // Ya se envió hoy
await this.connection.getRepository(ctx, CiNotificationLog).save(
new CiNotificationLog({
dedupeKey,
recipient,
category,
entityRef,
channelId: ctx.channelId,
sentAt: new Date(),
}),
);
await this.emailService.send(recipient, category, entityRef);
return true;
}
}
La clave de deduplicación incluye un bucket diario. La misma notificación puede enviarse de nuevo mañana pero nunca dos veces el mismo día. La restricción de unicidad sobre dedupeKey previene condiciones de carrera entre pods.
Patrones de esquema GraphQL
Extiende las APIs de Shop y Admin de Vendure con patrones GraphQL estándar:
export const reviewShopSchema = gql`
type CiReview implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
rating: Int!
body: String!
status: String!
customerName: String
}
type CiReviewList implements PaginatedList {
items: [CiReview!]!
totalItems: Int!
}
input SubmitReviewInput {
productId: ID!
rating: Int!
body: String!
}
extend type Query {
productReviews(productId: ID!, options: CiReviewListOptions): CiReviewList!
}
extend type Mutation {
submitReview(input: SubmitReviewInput!): CiReview!
}
input CiReviewListOptions
`;
// Opciones de ordenación/filtrado generadas automáticamente via ListQueryBuilder de Vendure
El ListQueryBuilder de Vendure genera automáticamente opciones de ordenación y filtrado para cualquier entidad que implemente PaginatedList. Obtienes filtrado por cualquier columna y ordenación de forma gratuita.
Desarrollo de dashboards (React)
La interfaz de administración basada en React de Vendure 3 proporciona puntos de extensión para dashboards de plugins:
// dashboard/routes.ts
import { DashboardRouteDefinition } from '@vendure/dashboard';
import { ReviewListPage } from './pages/ReviewListPage';
import { ReviewDetailPage } from './pages/ReviewDetailPage';
export const routes: DashboardRouteDefinition[] = [
{
path: 'reviews',
component: ReviewListPage,
navMenuItem: {
id: 'ci-reviews',
label: 'Reviews',
icon: 'star',
section: 'marketing',
},
},
{
path: 'reviews/:id',
component: ReviewDetailPage,
},
];
Las páginas del dashboard usan TanStack Query para la obtención de datos y los componentes del Dashboard SDK de Vendure:
// dashboard/pages/ReviewListPage.tsx
import { Page, PageTitle, PageBlock } from '@vendure/dashboard';
import { useQuery } from '@tanstack/react-query';
import { graphql } from '../codegen';
const ALL_REVIEWS_QUERY = graphql(`
query AllReviews($options: CiReviewListOptions) {
allReviews(options: $options) {
items { id rating body status createdAt }
totalItems
}
}
`);
export function ReviewListPage() {
const { data, isLoading } = useQuery({
queryKey: ['reviews'],
queryFn: () => adminClient.query(ALL_REVIEWS_QUERY),
});
return (
<Page>
<PageTitle>Reviews</PageTitle>
<PageBlock>
{/* Renderiza la lista de reseñas con controles de moderación */}
</PageBlock>
</Page>
);
}
Para nuestra guía de arquitectura Vendure en producción, que cubre la evaluación más amplia de la plataforma incluyendo las capacidades de la interfaz de administración React.
Testing
Tests unitarios
Prueba los métodos de servicio con dependencias mockeadas:
describe('ReviewService', () => {
it('should reject review with rating > 5', async () => {
const service = createTestService();
await expect(
service.submit(mockCtx, { productId: '1', rating: 6, body: 'Great' }),
).rejects.toThrow('Rating must be between 1 and 5');
});
});
Tests E2E
Prueba la API GraphQL completa con un servidor Vendure real:
import { createTestEnvironment } from '@vendure/testing';
describe('Review 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(() => server.destroy());
it('should submit a review', async () => {
const { submitReview } = await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 5, body: 'Excellent product' },
});
expect(submitReview.status).toBe('pending');
});
it('should not allow unauthenticated reviews', async () => {
await shopClient.asAnonymousUser();
const result = await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 5, body: 'Test' },
});
expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
it('should only show approved reviews publicly', async () => {
// Enviar reseña (pendiente)
await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 4, body: 'Good' },
});
// La consulta pública no debería mostrar reseñas pendientes
await shopClient.asAnonymousUser();
const { productReviews } = await shopClient.query(PRODUCT_REVIEWS, { productId: '1' });
expect(productReviews.items.every(r => r.status === 'approved')).toBe(true);
});
});
El createTestEnvironment de Vendure levanta un servidor real con SQLite, ejecuta migraciones, siembra datos de prueba y proporciona clientes autenticados para ambas APIs. Los tests se ejecutan contra la superficie GraphQL real.
Para patrones de testing más amplios, consulta nuestra guía de ingeniería de software.
Errores comunes
-
Nombres de tablas escritos a mano. Usa constantes de
shared/constants. Con más de 30 entidades, las cadenas escritas a mano garantizan erratas e inconsistencias. -
Resolvers de Shop/Admin mezclados. Clases separadas. Siempre. Una sola clase de resolver con queries públicas y administrativas es un incidente de seguridad esperando a ocurrir.
-
Importaciones de servicios entre módulos. Usa el EventBus. Si el módulo A importa el servicio del módulo B, has creado un acoplamiento que se rompe cuando cualquiera de los módulos cambia.
-
Sin filtrado por canal. Cada consulta debe filtrar por
ctx.channelId. Si te lo saltas, los datos se filtran entre tiendas. -
setInterval para scheduling. Usa el JobQueue de Vendure.
setIntervalse ejecuta en cada pod, no tiene recuperación ante fallos ni observabilidad. -
Sin deduplicación de notificaciones. Sin un almacén de deduplicación, cada pod que recibe un evento envía el mismo email. Los clientes reciben 3 copias de cada notificación.
-
Probar solo con SQLite. TypeORM se comporta diferente en SQLite y PostgreSQL. Los tipos de columna, el manejo de JSON y el aislamiento de transacciones difieren. Prueba con PostgreSQL antes de desplegar.
-
Sin idempotencia en las mutaciones. Los reintentos de red crean listas de deseos duplicadas, reseñas duplicadas, transacciones de fidelización duplicadas. Construye la idempotencia desde el primer día.
Conclusiones clave
-
Las convenciones de entidades previenen el caos a escala. Constantes para nombres de tablas, prefijo
Ci, clase baseVendureEntity, filtrado por canal y eliminación suave. Estos patrones son aburridos pero esenciales cuando tienes más de 30 entidades. -
La separación de resolvers es una frontera de seguridad. Los resolvers de Shop exponen datos públicos/de propietario. Los resolvers de Admin exponen operaciones de gestión. Nunca los mezcles.
-
El EventBus es la única comunicación entre módulos. Nada de importar servicios de otros módulos. Los eventos mantienen los módulos independientes y eliminables.
-
La coordinación multi-pod no es automática. Elección de líder para schedulers, deduplicación para notificaciones, idempotencia para mutaciones. Planifica para múltiples pods desde el primer día.
-
Prueba con un servidor Vendure real.
createTestEnvironmentte da un servidor real con GraphQL real. Prueba permisos, aislamiento de datos y reglas de negocio contra la superficie de API real.
Construimos plugins empresariales para Vendure como parte de nuestra práctica de ecommerce. Si estás construyendo un plugin Vendure complejo o necesitas ayuda con la arquitectura de plugins, habla con nuestro equipo o solicita un presupuesto. Consulta también nuestra guía de Vendure headless commerce para más información sobre nuestro trabajo con Vendure.
Temas cubiertos
Guías relacionadas
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.
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