Construire des Plugins Vendure Entreprise : Les Patterns qui Survivent au Déploiement Multi-Pod
Patterns de production pour le développement de plugins Vendure. Conventions d'entités, séparation des resolvers, communication EventBus, élection de leader, déduplication de notifications et tests avec de vrais serveurs.
Au-delà du tutoriel plugin
Tous les tutoriels Vendure te montrent comment créer un plugin avec une entité, un resolver et un service. Ça te fait passer de zéro à "ça marche sur mon laptop." Ça ne te mène pas à la production avec plusieurs pods, des workers en arrière-plan, la déduplication d'emails, et 30+ entités réparties sur 6 modules qui communiquent sans dépendances circulaires.
Nous avons construit deux plugins Vendure entreprise. Le Data Hub Plugin est un moteur de pipeline ETL avec 9 extracteurs, 61 opérateurs de transformation, et 24 chargeurs d'entités. Le Customer Intelligence Plugin couvre les wishlists, les avis, les programmes de fidélité, la récupération de paniers abandonnés, les alertes retour en stock, et les articles consultés récemment. À eux deux, ils représentent 50+ entités, des dizaines de resolvers, plusieurs files de jobs, et des dashboards construits avec React et TanStack.
Cet article couvre les patterns qui ont survécu à la production. Pour l'évaluation de l'architecture au niveau plateforme, consulte notre guide d'architecture Vendure en production. Cet article va plus en profondeur dans les patterns d'implémentation de plugins.
Conventions d'Entités
Constantes de Noms de Tables
Ne code jamais en dur les noms de tables. Avec 30+ entités réparties sur plusieurs plugins, les chaînes codées en dur deviennent un cauchemar de maintenance.
// shared/constants/index.ts
export const TABLE_NAMES = {
// Module Wishlist
WISHLIST: 'ci_wishlist',
WISHLIST_ITEM: 'ci_wishlist_item',
// Module Avis
REVIEW: 'ci_review',
REVIEW_VOTE: 'ci_review_vote',
REVIEW_RESPONSE: 'ci_review_response',
// Module Fidélité
LOYALTY_ACCOUNT: 'ci_loyalty_account',
LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
LOYALTY_TIER: 'ci_loyalty_tier',
// Module Récupération de Panier
ABANDONED_CART: 'ci_abandoned_cart',
RECOVERY_FLOW: 'ci_recovery_flow',
// Infrastructure
IDEMPOTENCY_KEY: 'ci_idempotency_key',
NOTIFICATION_LOG: 'ci_notification_log',
AUDIT_ENTRY: 'ci_audit_entry',
} as const;
Le préfixe ci_ empêche les collisions avec les tables du core Vendure et les autres plugins. Chaque entité utilise la constante, jamais une chaîne littérale.
Pattern de Base d'Entité
Chaque entité suit la même structure :
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;
}
Règles :
- Chaque entité étend
VendureEntity(fournitid,createdAt,updatedAt) - Chaque entité utilise
TABLE_NAMES.Xpour le nom de table - Chaque entité inclut
channelIdpour l'isolation multi-canal - Les noms de classes utilisent le préfixe
Ci:CiReview,CiWishlist,CiLoyaltyAccount - Le constructeur accepte
DeepPartial<T>pour une instanciation propre - Les index sont définis sur l'entité, pas dans les migrations, pour la visibilité
Suppression Douce
Pour les entités qui ont besoin d'un historique d'audit ou de récupération, ajoute deletedAt au lieu de supprimer en dur. Filtre dans toutes les requêtes :
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 }));
}
Séparation des Resolvers
Les resolvers de la Shop API (côté client) et de l'Admin API (backoffice) sont toujours des classes séparées. Ce n'est pas une suggestion. C'est une contrainte de conception qui empêche l'exposition accidentelle d'opérations admin aux clients.
// 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); // inclut pending, rejected
}
@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);
}
}
Le resolver Shop expose productReviews (approuvés uniquement, accès public). Le resolver Admin expose allReviews (tous les statuts, accès admin) et moderateReview (admin uniquement). Ils ne partagent jamais la même classe.
Communication EventBus
Les modules communiquent uniquement via l'EventBus. Le module wishlist n'importe pas le service du module fidélité. Le module avis n'importe pas le service du module notification.
// Le module A émet un événement
@Injectable()
export class ReviewService {
constructor(private eventBus: EventBus) {}
async submit(ctx: RequestContext, input: SubmitReviewInput): Promise<CiReview> {
const review = await this.createReview(ctx, input);
// Émettre l'événement, ne pas appeler le service fidélité directement
await this.eventBus.publish(new CiReviewSubmittedEvent(ctx, review));
return review;
}
}
// Le module B s'abonne et traite
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
this.eventBus.ofType(CiReviewSubmittedEvent).subscribe(async event => {
// Attribuer des points de fidélité pour la soumission d'un avis
await this.loyaltyService.awardPoints(
event.ctx,
event.review.customerId,
'review_submitted',
50, // points
);
});
}
}
Cela garde les frontières entre modules propres. Tu peux retirer le module fidélité sans casser le module avis. L'événement est publié qu'il y ait des abonnés ou non.
Pour voir comment nous appliquons les patterns EventBus dans des systèmes hors Vendure, consulte notre guide d'architecture event-driven.
Coordination Multi-Pod
Élection de Leader pour les Schedulers
Les jobs planifiés (détection de paniers abandonnés, expiration de points de fidélité, vérification de baisses de prix) doivent s'exécuter sur exactement un pod. Sans élection de leader, chaque pod exécute le scheduler, et tu obtiens du traitement en double.
La JobQueue de Vendure gère cela. Un seul worker prend un job de la file. Planifie les jobs via la file, pas via 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);
},
});
}
// Appelé par un scheduler ou un déclencheur cron
async scheduleDetection(ctx: RequestContext) {
await this.detectionQueue.add({}, { ctx });
}
}
Déduplication des Notifications
Quand tous les pods consomment le même événement, chaque pod peut essayer d'envoyer la même notification. Le store de déduplication empêche les doublons :
@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; // Déjà envoyé aujourd'hui
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 clé de déduplication inclut un bucket journalier. La même notification peut être renvoyée le lendemain mais jamais deux fois le même jour. La contrainte unique sur dedupeKey empêche les conditions de course entre pods.
Patterns de Schéma GraphQL
Étends les APIs Shop et Admin de Vendure avec des patterns GraphQL standard :
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
`;
// Options de tri/filtre auto-générées via le ListQueryBuilder de Vendure
Le ListQueryBuilder de Vendure génère automatiquement les options de tri et de filtre pour toute entité qui implémente PaginatedList. Tu obtiens le filtrage par colonne et le tri gratuitement.
Développement de Dashboard (React)
L'UI admin basée sur React de Vendure 3 fournit des points d'extension pour les 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,
},
];
Les pages de dashboard utilisent TanStack Query pour le data fetching et les composants du SDK Dashboard 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>
{/* Afficher la liste des avis avec les contrôles de modération */}
</PageBlock>
</Page>
);
}
Notre guide d'architecture Vendure en production couvre l'évaluation plus large de la plateforme, y compris les capacités de l'UI admin React.
Tests
Tests Unitaires
Teste les méthodes de service avec des dépendances mockées :
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
Teste l'API GraphQL complète avec un vrai serveur Vendure :
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 () => {
// Soumettre un avis (en attente)
await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 4, body: 'Good' },
});
// La requête publique ne devrait pas montrer les avis en attente
await shopClient.asAnonymousUser();
const { productReviews } = await shopClient.query(PRODUCT_REVIEWS, { productId: '1' });
expect(productReviews.items.every(r => r.status === 'approved')).toBe(true);
});
});
Le createTestEnvironment de Vendure démarre un vrai serveur avec SQLite, exécute les migrations, injecte les données de test, et fournit des clients authentifiés pour les deux APIs. Les tests tournent contre la vraie surface GraphQL.
Pour des patterns de test plus larges, consulte notre guide d'ingénierie logicielle.
Pièges Courants
-
Noms de tables codés en dur. Utilise les constantes de
shared/constants. Avec 30+ entités, les chaînes codées en dur garantissent des fautes de frappe et des incohérences. -
Resolvers Shop/Admin mélangés. Classes séparées. Toujours. Une classe de resolver unique avec des requêtes publiques et admin, c'est un incident de sécurité qui attend de se produire.
-
Imports de services cross-module. Utilise l'EventBus. Si le module A importe le service du module B, tu as créé un couplage qui casse quand l'un des modules change.
-
Pas de scoping par canal. Chaque requête doit filtrer par
ctx.channelId. Oublier ça fait fuiter les données entre les storefronts. -
setInterval pour la planification. Utilise la
JobQueuede Vendure.setIntervaltourne sur chaque pod, n'a pas de récupération en cas d'échec, et pas d'observabilité. -
Pas de déduplication des notifications. Sans store de déduplication, chaque pod qui reçoit un événement envoie le même email. Les clients reçoivent 3 copies de chaque notification.
-
Tester uniquement avec SQLite. TypeORM se comporte différemment sur SQLite et PostgreSQL. Les types de colonnes, la gestion du JSON, et l'isolation des transactions diffèrent. Teste avec PostgreSQL avant de livrer.
-
Pas d'idempotence sur les mutations. Les retries réseau créent des wishlists en double, des avis en double, des transactions de fidélité en double. Construis l'idempotence dès le premier jour.
Points Clés
-
Les conventions d'entités empêchent le chaos à grande échelle. Constantes de noms de tables, préfixe
Ci, classe de baseVendureEntity, scoping par canal, et suppressions douces. Ces patterns sont ennuyeux mais essentiels quand tu as 30+ entités. -
La séparation des resolvers est une frontière de sécurité. Les resolvers Shop exposent les données publiques/propriétaire. Les resolvers Admin exposent les opérations de gestion. Ne les mélange jamais.
-
L'EventBus est la seule communication inter-module. Pas d'imports de services cross-module. Les événements gardent les modules indépendants et retirables.
-
La coordination multi-pod n'est pas automatique. Élection de leader pour les schedulers, déduplication pour les notifications, idempotence pour les mutations. Prévois plusieurs pods dès le premier jour.
-
Teste avec un vrai serveur Vendure.
createTestEnvironmentte donne un vrai serveur avec du vrai GraphQL. Teste les permissions, l'isolation des données, et les règles métier contre la vraie surface API.
Nous construisons des plugins Vendure entreprise dans le cadre de notre pratique ecommerce. Si tu construis un plugin Vendure complexe ou si tu as besoin d'aide sur l'architecture de plugin, parle à notre équipe ou demande un devis. Consulte aussi notre guide Vendure headless commerce pour en savoir plus sur notre travail avec Vendure.
Sujets couverts
Guides connexes
Vendure en Production : Forces, Limites et Quand le Choisir
Une évaluation honnête de l'architecture Vendure en production. Système de plugins, EventBus, service Worker, AdminUI, déploiement multi-pods et comparaison avec Medusa et Saleor.
Lire le guideGuide Entreprise des Systèmes d'IA Agentiques
Guide technique des systemes d'IA agentiques en entreprise. Decouvre l'architecture, les capacites et les applications des agents IA autonomes.
Lire le guideCommerce Agentique : Comment laisser les agents IA acheter en toute securite
Comment concevoir un commerce agentique gouverne. Moteurs de politiques, portes d'approbation HITL, reçus HMAC, idempotence, isolation multi-tenant et le protocole Agentic Checkout complet.
Lire le guidePrêt à construire des systèmes IA prêts pour la production ?
Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.
Démarrer une conversation