Guide technique

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.

15 mars 202620 min de lectureÉquipe d'Ingénierie Oronts

Pourquoi Nous Avons Choisi Vendure (et Quand Nous Ne Le Ferions Pas)

On construit sur Vendure depuis ses premières versions, et on adore sincèrement travailler avec. L'expérience développeur est exceptionnelle. Tu écris du TypeScript partout, l'injection de dépendances NestJS rend les services propres et testables, le système de plugins te permet d'étendre n'importe quoi sans forker, et l'API GraphQL est bien conçue depuis le premier jour. En tant qu'équipe d'architectes et d'ingénieurs seniors, on accorde une grande importance à la DX, et Vendure la délivre mieux que n'importe quelle plateforme commerce avec laquelle on a travaillé.

Ce n'est pas du marketing. C'est ce qui se passe quand tu construis deux plugins enterprise sur un framework et que tout fonctionne exactement comme tu t'y attends. L'un est un système complet de pipelines ETL (Vendure Data Hub Plugin) avec 9 extracteurs, 61 opérateurs de transformation et 24 chargeurs d'entités. L'autre est une suite d'intelligence client à 6 modules couvrant les wishlists, les avis, les programmes de fidélité, la récupération de paniers, les alertes de retour en stock et les articles consultés récemment. On a choisi Vendure pour ces projets parce que l'architecture de plugins nous donnait la flexibilité de construire des systèmes complexes sans se battre contre le framework. Le code reste propre, les patterns restent cohérents, et le refactoring est sûr parce que TypeScript attrape tout à la compilation.

Cela dit, Vendure n'est pas parfait pour tous les cas d'usage. Cet article est une évaluation honnête du point de vue de l'architecture système. Si tu es CTO en train d'évaluer des plateformes commerce, lead engineer en train de planifier un build, ou architecte en train de concevoir un système multicanal, voici ce que tu dois savoir. Pour plus de contexte sur notre approche des décisions de plateformes ecommerce, ce guide couvre l'ensemble du paysage.

Vue d'Ensemble de l'Architecture

Vendure est un framework commerce headless construit sur NestJS (Node.js), TypeORM et GraphQL. Le core fournit les produits, commandes, clients, paiements, livraisons, promotions et un système de plugins pour tout étendre.

┌─────────────────────────────────────────────────────────────┐
│                     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    │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Ce Que Vendure Fait Brillamment

1. Le Système de Plugins

C'est le différenciateur le plus fort de Vendure. Un plugin peut étendre chaque couche du système : entités, services, resolvers, schéma GraphQL, admin UI, workers et gestionnaires d'événements. Tout ça sans modifier le code core.

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) => {
        // Modifier la config core si nécessaire
        return config;
    },
})
export class WishlistPlugin {}

Ce qui le rend vraiment bon :

  • Extension d'entités : Ajoute de nouvelles entités TypeORM qui obtiennent leurs propres tables, migrations et relations
  • Extension de schéma : Étends les schémas GraphQL de la Shop API et de l'Admin API indépendamment
  • Séparation des resolvers : Les resolvers Shop API (côté client) et Admin API (backoffice) sont toujours des classes séparées. C'est une contrainte de design qui empêche l'exposition accidentelle d'opérations admin aux clients
  • Injection de services : Injection de dépendances NestJS complète. Les services de ton plugin peuvent injecter les services core de Vendure
  • Custom fields : Ajoute des champs à n'importe quelle entité core (Product, Customer, Order) sans toucher au code source de Vendure

On a construit deux plugins enterprise avec ce système. Le Data Hub Plugin ajoute un moteur complet de pipelines ETL avec 9 extracteurs de données, 61 opérateurs de transformation et 24 chargeurs d'entités. Le Customer Intelligence Plugin ajoute 6 modules d'engagement client avec leurs propres entités, APIs GraphQL, tableaux de bord admin et jobs en arrière-plan. Les deux tournent en production sans modifier une seule ligne du core Vendure.

2. L'EventBus

L'EventBus de Vendure est l'épine dorsale du couplage faible entre modules. Chaque action significative dans le système émet un événement :

// Vendure core émet des événements automatiquement
ProductEvent          // produit créé/modifié/supprimé
OrderStateTransitionEvent  // changements d'état de commande
CustomerEvent         // client créé/modifié
OrderPlacedEvent      // commande passée avec succès
RefundStateTransitionEvent // changements de statut de remboursement

// Tes plugins émettent des événements personnalisés
export class CiWishlistItemAddedEvent extends VendureEvent {
    constructor(
        public ctx: RequestContext,
        public wishlistId: string,
        public productVariantId: string,
    ) {
        super();
    }
}

// D'autres modules s'abonnent sans importer le module émetteur
@Injectable()
export class StockSubscriber {
    constructor(private eventBus: EventBus) {
        this.eventBus.ofType(OrderPlacedEvent).subscribe(event => {
            // Mettre à jour le stock, notifier l'entrepôt, etc.
        });
    }
}

La règle architecturale clé : les modules communiquent uniquement via l'EventBus. Pas d'imports de services entre modules. Ça empêche les dépendances circulaires et garde les modules déployables indépendamment. Quand on a construit le Customer Intelligence Plugin avec 6 modules (wishlist, avis, fidélité, récupération de panier, retour en stock, consultés récemment), chaque module ne connaît que ses propres services. La coordination inter-modules (par ex. points de fidélité pour un avis) passe par les événements.

3. TypeScript de Bout en Bout (La DX Qui Nous Fait Rester)

C'est là que Vendure brille vraiment comme expérience développeur. Toute la stack est TypeScript : serveur, plugins, admin UI, génération de code GraphQL. Tout est typé, tout compile, tout attrape les erreurs avant le runtime.

  • Contrats d'API type-safe de l'entité base de données au schéma GraphQL jusqu'au composant frontend
  • Le schéma GraphQL génère automatiquement les types TypeScript avec codegen
  • Renomme un champ dans ton entité, et le compilateur te montre chaque endroit qui doit être mis à jour
  • Un seul langage pour les services backend, le développement de plugins, le tableau de bord admin et les suites de tests
  • Les décorateurs NestJS (@Injectable(), @Transaction(), @Allow()) rendent l'intention explicite dans le code
  • Le RequestContext traverse chaque méthode de service, portant le contexte d'authentification, de channel et de locale

Ce n'est pas anodin. Dans les plateformes commerce basées sur PHP (Magento, Sylius, même la couche commerce de Pimcore), tu perds la sûreté de typage à chaque frontière. Avec Vendure, un changement cassant dans ton entité produit apparaît comme une erreur de compilation dans ta requête storefront. Cette boucle de feedback, c'est la différence entre passer 2 minutes à corriger un type mismatch et passer 2 heures à débugger un bug en production.

La flexibilité est aussi remarquable. Tu as besoin d'un flux de checkout personnalisé ? Écris un service. Tu dois modifier la façon dont les promotions s'appliquent ? Override la stratégie de promotion. Tu as besoin d'une entité entièrement nouvelle avec sa propre API GraphQL, page de tableau de bord admin et job en arrière-plan ? Le système de plugins gère tout ça avec des patterns propres qui restent cohérents au fur et à mesure que le système grandit. On ne s'est jamais senti limité par le framework, et c'est rare pour les plateformes commerce.

4. Worker Service (BullMQ)

Vendure sépare les opérations longues dans un service Worker dédié, adossé à BullMQ (Redis) :

// Définir une file d'attente de jobs dans ton 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);
    },
});

// Ajouter à la file depuis n'importe où
await this.myJobQueue.add({ productId: '123' }, { ctx });

Le service Worker tourne comme un processus séparé. En Kubernetes, c'est un déploiement séparé avec un scaling indépendant. Ça sépare proprement le traitement des requêtes (pods web) du traitement en arrière-plan (pods worker).

Pour comprendre comment on pense les patterns event-driven et les architectures de jobs en arrière-plan, notre guide d'ingénierie couvre les principes plus larges.

5. Design de l'API GraphQL

Vendure fournit deux APIs GraphQL séparées :

  • Shop API : Opérations côté client (parcourir les produits, passer des commandes, gérer le compte)
  • Admin API : Opérations backoffice (gérer les produits, traiter les commandes, configurer le système)

Les deux supportent :

  • Pagination automatique avec ListQueryBuilder
  • Filtrage et tri intégrés
  • Permissions au niveau des champs
  • Custom fields sur n'importe quelle entité
// ListQueryBuilder génère du SQL efficace avec pagination, tri, filtrage
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. L'Admin UI React (Vendure 3)

Vendure 3 embarque une admin UI entièrement basée sur React, conçue pour l'extensibilité dès le départ. Elle remplace l'ancienne admin Angular de Vendure 1/2 et c'est un pas en avant majeur.

Les développeurs de plugins peuvent :

  • Ajouter des pages et routes personnalisées au tableau de bord admin
  • Étendre les vues existantes avec des composants personnalisés
  • Construire des expériences complètes de tableau de bord en utilisant React, TanStack Query et TanStack Router
  • Utiliser les composants du Dashboard SDK de Vendure (Page, PageTitle, DetailFormGrid, ListPage)
  • Enregistrer des éléments de menu personnalisés, des widgets et des onglets de détail

On a construit nos tableaux de bord de plugins avec cette stack. La DX est solide et naturelle pour tout développeur React.

7. Stratégies et Points d'Extension

Vendure fournit des interfaces de stratégie pour personnaliser le comportement commerce core sans modifier le code source :

// Condition de promotion personnalisée
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;
    }
}

// Calculateur d'expédition personnalisé
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 };
    }
}

Le calcul de taxes, le traitement des paiements, l'exécution des commandes, le stockage d'assets et l'indexation de recherche ont tous des interfaces de stratégie. Tu peux changer la façon dont la TVA est calculée pour un pays spécifique, comment les paiements sont capturés, ou comment les commandes sont exécutées sans toucher au code core. Le framework fournit les hooks, toi tu fournis la logique métier.

8. Custom Fields sur les Entités Core

L'une des fonctionnalités les plus pragmatiques de Vendure. Ajoute des champs à n'importe quelle entité core sans migrations ni modifications du core :

// Dans la configuration de ton 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' }] },
    ],
}

Les custom fields apparaissent automatiquement dans l'admin UI, sont inclus dans les schémas GraphQL et sont requêtables via l'API. Ils sont stockés dans la même table que l'entité core, donc les jointures sont gratuites. Tu peux filtrer et trier par custom fields en utilisant le ListQueryBuilder standard de Vendure. C'est comme ça que tu fais le pont entre Vendure et les systèmes externes : le erpId sur Product devient la clé de jointure pour la synchronisation ERP, le erpCustomerId sur Customer fait le lien avec ton CRM, et le erpOrderId sur Order suit quelles commandes ont été exportées.

Les custom fields supportent aussi les relations vers d'autres entités, les chaînes localisées (traduites par langue) et les hooks de validation. C'est le mécanisme principal pour adapter Vendure à ton domaine sans tables d'extension séparées.

9. Stratégies de Pricing Personnalisées

Le système de pricing de Vendure est basé sur des stratégies. Par défaut, il calcule le prix à partir du prix stocké de la variante produit. Mais en commerce enterprise, le pricing est rarement aussi simple. Tu peux avoir besoin de prix venant d'un ERP, de remises spécifiques par client, de paliers basés sur le volume, ou de calculs de prix en temps réel.

// Stratégie de pricing personnalisée qui récupère les prix depuis un ERP externe
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> {
        // Vérifier si la variante a un SKU ERP
        const erpSku = productVariant.customFields?.supplierSku;
        if (!erpSku) {
            // Fallback vers le prix stocké dans Vendure
            return {
                price: productVariant.listPrice,
                priceIncludesTax: productVariant.listPriceIncludesTax,
            };
        }

        // Récupérer le prix en temps réel depuis l'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,
        };
    }
}

Enregistre la stratégie dans ta config Vendure :

orderOptions: {
    orderItemPriceCalculationStrategy: new ErpPricingStrategy(),
}

C'est comme ça que les installations Vendure enterprise gèrent le pricing spécifique par client, les prix contractuels et les calculs de prix dynamiques. La stratégie reçoit le contexte complet de la commande (client, quantité, devise) pour que tu puisses implémenter n'importe quelle logique de pricing requise par ton business. Tu peux aussi combiner ça avec le système de promotions intégré de Vendure pour empiler des remises par-dessus les prix de base venant de l'ERP.

10. Requêtes GraphQL Personnalisées pour l'Intégration de Systèmes Externes

L'un des patterns les plus puissants de Vendure pour l'intégration enterprise : tu peux ajouter des requêtes et mutations GraphQL personnalisées qui tirent des données de n'importe quel système externe et les exposent via l'API de Vendure. Ça transforme Vendure en une couche d'API commerce unifiée qui agrège les données des ERPs, PIMs, entrepôts et autres backends.

// Extension de schéma : ajouter une requête qui récupère le stock en temps réel depuis 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 : récupérer depuis l'API 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);
    }
}

// Service : gère les appels API ERP avec cache et gestion d'erreurs
@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 }); // cache 5 min
        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 en centimes
                currency: ctx.currencyCode,
                priceListName: response.data.priceList,
                validUntil: response.data.validUntil,
            };
        } catch (error) {
            // ERP indisponible : retourner null, laisser le frontend revenir au prix catalogue
            return null;
        }
    }
}

Le storefront interroge ensuite Vendure pour les données catalogue et les données ERP via un seul 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
    }
}

Une seule requête. Données catalogue depuis la base Vendure, stock depuis l'ERP, pricing spécifique au client depuis l'ERP. Le storefront n'a pas besoin de connaître l'ERP. Il interroge juste Vendure. Ce pattern fonctionne avec n'importe quel système externe : APIs REST, services SOAP, endpoints GraphQL, backends gRPC. Vendure devient la couche de composition.

Pour comprendre comment on construit ces pipelines d'intégration à grande échelle (syncs planifiés, listeners de webhooks, imports en masse), le Data Hub Plugin gère tout le côté ETL. Les requêtes GraphQL personnalisées gèrent le côté temps réel, par requête.

Où Vendure a des Lacunes

1. Pas de Multi-Entrepôt Intégré

Vendure a un modèle de stock à un seul emplacement. Si tu as besoin d'un inventaire multi-entrepôt (stock dans l'entrepôt A, stock différent dans l'entrepôt B, routage de fulfillment par emplacement), tu dois le construire toi-même ou utiliser un plugin tiers.

C'est un manque significatif pour toute opération commerce avec plus d'un emplacement physique. Le contournement (custom fields + logique d'allocation de stock personnalisée) est fragile et ne s'intègre pas avec le flux de fulfillment intégré de Vendure.

2. Fonctionnalités B2B Limitées

Vendure est principalement conçu pour le commerce B2C. Les exigences B2B comme :

  • Comptes entreprise avec plusieurs acheteurs
  • Workflows d'approbation pour les commandes
  • Pricing spécifique par client avec des règles complexes
  • Gestion de devis
  • Bons de commande
  • Contrôles de budget par acheteur

Tout ça nécessite un développement personnalisé. Le système de Channels offre une certaine capacité multi-tenant, mais ce n'est pas un ensemble de fonctionnalités B2B.

3. La Recherche est Basique

La recherche intégrée de Vendure utilise un index full-text adossé à la base de données. Ça fonctionne pour les petits catalogues mais ne scale pas pour :

  • Recherche à facettes avec des filtres complexes
  • Tolérance aux fautes de frappe
  • Gestion des synonymes
  • Recherche multilingue avec des analyseurs spécifiques par langue
  • Mises à jour d'index en temps réel

Pour la recherche en production, tu as besoin d'un moteur externe (MeiliSearch, Elasticsearch, Algolia). Notre Data Hub Plugin inclut des sinks de recherche pour MeiliSearch, Elasticsearch, OpenSearch, Algolia et Typesense avec indexation en temps réel via les événements Vendure. Pour en savoir plus sur l'architecture de recherche en commerce, ce guide couvre les détails techniques.

4. Outillage de Migrations

Les migrations TypeORM dans Vendure peuvent être fragiles. Quand plusieurs plugins définissent des entités, l'ordre de génération des migrations devient imprévisible. On a rencontré :

  • Des migrations qui référencent des tables pas encore créées par les migrations d'autres plugins
  • Des incompatibilités de types de colonnes entre SQLite (dev) et PostgreSQL (prod)
  • Des conflits de migration quand deux plugins modifient la même entité core via des custom fields

Le contournement : générer les migrations par plugin, tester avec PostgreSQL (pas SQLite), et maintenir un ordre strict de migration dans tes scripts de déploiement. On gère des défis similaires de migration dans notre guide de mise à jour Pimcore, où la gestion de schéma de base de données est encore plus complexe.

Patterns d'Architecture d'Entités

Après avoir construit deux plugins enterprise, ces patterns se sont imposés comme non négociables pour la qualité production.

Convention de Préfixe d'Entités

Chaque entité de plugin utilise un préfixe pour éviter les collisions de nommage :

// Noms de tables définis dans des constantes (jamais en dur)
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',
    // ...
};

// L'entité utilise 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;
}

Chaque entité étend VendureEntity (fournit id, createdAt, updatedAt). Chaque entité inclut channelId pour l'isolation multi-channel. Chaque nom de table vient d'une constante partagée, jamais de chaînes en dur.

Soft Deletes

Pour les entités qui ont besoin de pistes d'audit, utilise une colonne deletedAt au lieu de suppressions définitives :

@Column({ type: 'datetime', nullable: true })
deletedAt?: Date;

Ça préserve l'intégrité référentielle et permet la récupération. Filtre les enregistrements supprimés dans toutes les requêtes par défaut.

Scoping par Channel

Chaque requête doit être scopée au channel actif. Le RequestContext de Vendure porte l'information de channel :

async findByCustomer(ctx: RequestContext, customerId: number) {
    return this.connection.getRepository(ctx, CiWishlist).find({
        where: {
            customerId,
            channelId: ctx.channelId,
        },
    });
}

Sans scoping par channel, les données d'un storefront fuient vers un autre. C'est le bug de sécurité le plus courant dans les déploiements Vendure multi-channel. On couvre des patterns similaires d'isolation de données multi-tenant dans notre guide de gouvernance IA et notre page de confiance.

Patterns de Tests

Vendure supporte les tests unitaires et e2e pour les plugins. Le setup de tests est l'une des forces sous-estimées du framework.

Tests Unitaires

Teste les méthodes de service avec des repositories mockés :

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

Teste l'API GraphQL complète avec un vrai serveur Vendure :

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

Le createTestEnvironment de Vendure démarre un vrai serveur avec SQLite, lance les migrations, seed les données de test et fournit des clients GraphQL authentifiés pour la Shop API et l'Admin API. Les tests tournent contre la vraie surface d'API, pas des mocks. Ça attrape les problèmes de permissions, les incohérences de schéma et les bugs de validation de données que les tests unitaires ratent.

On utilise Vitest avec SWC pour les tests unitaires rapides et Vitest avec des processus forkés pour les tests e2e (nécessaire parce que le cycle de vie du serveur Vendure requiert une isolation de processus entre les suites de test). Pour comprendre comment on aborde les tests et la qualité dans notre pratique d'ingénierie plus large, consulte notre page méthodologie.

Coordination Multi-Pods

Faire tourner Vendure sur plusieurs pods introduit des défis de coordination qui n'existent pas dans les déploiements à instance unique.

Élection de Leader pour les Schedulers

Les jobs planifiés (détection de panier toutes les 15 minutes, expiration de fidélité quotidienne, vérification de baisse de prix toutes les heures) doivent tourner sur exactement une instance. Si tous les pods exécutent le scheduler, tu obtiens un traitement en double.

// Les services scheduler utilisent l'élection de leader via la JobQueue de Vendure
// Une seule instance traite le 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);
            },
        });

        // Planifié : tourne toutes les 15 minutes, mais sur un seul pod
        await this.jobQueue.add({}, { ctx: RequestContext.empty() });
    }
}

Déduplication par Clé Métier pour les Consommateurs d'Événements

Les consommateurs d'événements (notifications de baisse de prix, alertes de stock, demandes d'avis) tournent sur tous les pods. L'abonnement EventBus de chaque pod reçoit l'événement. La déduplication empêche l'envoi d'emails en double :

Type de ServiceComportement Multi-Instance
Schedulers (détection de panier, expiration fidélité)Élection de leader via verrou DB. Seule 1 instance tourne.
Consommateurs d'événements (baisses de prix, alertes stock)Toutes les instances consomment. Idempotent via dédup par clé métier.
// Déduplication par clé métier pour les notifications
const dedupeKey = `${recipientEmail}:${category}:${entityRef}:${dayBucket}`;
const existing = await this.notificationRepo.findOne({ where: { dedupeKey } });
if (existing) {
    return; // Déjà envoyé aujourd'hui
}

La clé de dédup inclut un dayBucket (date UTC YYYY-MM-DD) pour que la même notification puisse être envoyée à nouveau le lendemain, mais jamais deux fois le même jour.

Idempotence pour les Mutations API

Les mutations côté client (ajout à la wishlist, soumission d'avis, échange de points de fidélité) ont besoin d'idempotence pour gérer les retries et les pannes réseau :

@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 de l'input normalisé

    @Column({ type: 'varchar', length: 20 })
    status!: string;  // PENDING, COMPLETED, FAILED
}
// Contrainte unique : UNIQUE(scope, key)

Deux modèles d'idempotence distincts :

  • Idempotence API (CiIdempotencyKey) : Pour les mutations initiées par l'utilisateur. Clé fournie par le client ou générée à partir du hash de l'input. Rejoue la réponse en cache en cas de doublon.
  • Idempotence de job (au niveau de la file) : Pour le traitement en arrière-plan. Clé de dédup dans le payload du job. Vérifiée par le worker avant exécution. Utilise des contraintes DB ou des marqueurs de complétion.

Ne confonds jamais ces deux modèles. Ils ont des cycles de vie différents et des sémantiques de défaillance différentes.

Pour comprendre comment on gère des patterns de concurrence similaires dans nos systèmes, consulte notre guide sur la conception de workflows IA qui couvre des défis d'orchestration connexes.

Data Hub : ETL pour Vendure

L'un des plus grands manques du commerce headless est l'intégration de données. Comment faire entrer les produits de ton ERP dans Vendure ? Comment synchroniser l'inventaire entre les channels ? Comment générer les flux produits pour Google Merchant Center ?

On a construit le Vendure Data Hub Plugin pour résoudre ça. C'est un moteur complet de pipelines ETL qui tourne à l'intérieur de Vendure :

ComposantNombreExemples
Extracteurs9HTTP/REST, GraphQL, Base de données (SQL), Fichier (CSV/JSON/XML), S3, FTP/SFTP, Webhook, CDC
Opérateurs de Transformation61Chaînes (12), Date (5), Numérique (9), Logique (4), JSON (4), Données (8), Enrichissement (5), Agrégation (8), Validation (2)
Chargeurs d'Entités24Produits, Variantes, Clients, Collections, Commandes, Promotions, Assets, Facettes
Générateurs de Flux4Google Merchant Center, Meta Catalog, Amazon Seller Central, Personnalisé
Sinks de Recherche7Elasticsearch, OpenSearch, MeiliSearch, Algolia, Typesense, Producteurs de Files, Webhooks
Déclencheurs6Manuel, Planifié (cron), Webhook, Événements Vendure, Surveillance de Fichier, File de Messages

Le plugin utilise les mêmes patterns que le core Vendure : entités TypeORM, services NestJS, API GraphQL, intégration EventBus. Il inclut un éditeur visuel de pipelines dans le tableau de bord admin, des logs d'exécution en temps réel, la récupération par checkpoint (reprendre depuis le dernier enregistrement réussi) et des verrous distribués pour la sécurité multi-pods.

C'est le type d'infrastructure d'ingénierie de données dont le commerce enterprise a besoin mais qu'il obtient rarement de la plateforme commerce elle-même.

Vendure vs Medusa vs Saleor (2026)

Une comparaison honnête pour les architectes qui évaluent les plateformes :

CritèreVendureMedusa v2Saleor
LangageTypeScript (NestJS)TypeScript (framework custom)Python (Django + GraphQL)
APIGraphQL (Shop + Admin)REST + JS SDK + Admin APIGraphQL
Système de pluginsExcellent (entités, schéma, resolvers, admin)Bon (modules, workflows)Limité (apps via webhooks)
Base de donnéesPostgreSQL, MySQL, SQLite (TypeORM)PostgreSQL (MikroORM)PostgreSQL (Django ORM)
Admin UIReact (intégré, entièrement personnalisable)React (intégré)React (intégré, Dashboard)
Worker/JobsBullMQ (intégré)Système de jobs intégréCelery (Python)
Multi-channelChannels (intégré)Sales ChannelsChannels
Fonctionnalités B2BLimitées (dev custom nécessaire)En croissance (certaines intégrées)En croissance (certaines intégrées)
RechercheBasique (adossée à la DB)Basique (intégration nécessaire)Basique (intégration nécessaire)
Multi-entrepôtPas intégréIntégré (v2)Intégré
CommunautéMoyenne (en croissance)Large (croissance rapide)Moyenne
HébergementAuto-hébergéAuto-hébergé + Medusa CloudSaleor Cloud + auto-hébergé
MaturitéStable, éprouvé en productionv2 plus récent, changements d'API possiblesStable, éprouvé en production
DX pour équipes TypeScriptExcellenteBonneNécessite des connaissances Python
Plugins enterpriseÉcosystème solideEn croissanceBasé sur webhooks (limité)

Quand Choisir Vendure

  • Ton équipe écrit du TypeScript. L'avantage DX n'est pas marginal, il est transformateur. La sûreté de typage sur toute la stack commerce change la vitesse à laquelle tu peux livrer et la confiance avec laquelle tu peux refactorer.
  • Tu as besoin d'une extensibilité profonde par plugins. Aucune autre plateforme ne te permet d'ajouter des entités, d'étendre le schéma GraphQL, de construire des pages de tableau de bord admin et d'enregistrer des jobs en arrière-plan depuis une seule déclaration de plugin.
  • Tu construis du commerce B2C ou hybride à dominante B2C avec une logique métier personnalisée. Vendure te donne un core commerce solide et ne te gêne pas pour le reste.
  • Tu veux maîtriser ton infrastructure. Vendure tourne partout où Node.js tourne.
  • Tu as besoin d'une intégration étroite avec des microservices NestJS existants ou des backends TypeScript. Vendure est déjà NestJS, donc tes services parlent le même langage.
  • Tu valorises une architecture propre et une maintenabilité à long terme. Les patterns (EventBus, RequestContext, resolvers Shop/Admin séparés) scalent vers de gros codebases sans dégradation.

Quand Choisir Medusa

  • Tu as besoin du multi-entrepôt prêt à l'emploi.
  • Tu préfères REST à GraphQL pour ton storefront.
  • Tu veux une option cloud managée avec la flexibilité open-source.
  • Tu pars de zéro et tu veux les patterns de framework les plus récents.

Quand Choisir Saleor

  • Ton équipe est Python/Django.
  • Tu as besoin de l'offre cloud managée (Saleor Cloud).
  • Tu veux une internationalisation solide et du multi-devises intégrés.
  • Tu as besoin de fonctionnalités marketplace.

Quand Choisir Shopify

  • Tu ne veux pas gérer d'infrastructure.
  • Tes besoins commerce sont standards (catalogue, checkout, paiements).
  • Tu as besoin du plus grand écosystème d'apps.
  • Tu es prêt à accepter les contraintes de la plateforme pour un time-to-market plus rapide.

Pour notre perspective plus large sur les plateformes commerce headless et la direction que prend l'industrie, ce guide couvre l'ensemble du paysage.

Architecture de Déploiement Production

Stack Recommandée

ComposantTechnologieRôle
ServeurVendure (NestJS)API Commerce
Base de donnéesPostgreSQL 15+Store de données primaire
CacheRedis 7+Sessions, cache, files de jobs
RechercheMeiliSearch ou OpenSearchRecherche produit, filtrage à facettes
StockageS3 / Azure Blob / localStockage d'assets
CDNCloudFront / CloudflareDistribution d'assets
Message brokerRedis (BullMQ) ou RabbitMQFiles de jobs, traitement d'événements
MonitoringOpenTelemetry + GrafanaObservabilité

Architecture de Pods Kubernetes

PodRôleRéplicas
vendure-serverShop API + Admin API2-4
vendure-workerTraitement de jobs BullMQ1-3
postgresBase de données (ou managé)1+
redisCache + files1+
meilisearchMoteur de recherche1-2

Configuration d'Environnement

// vendure-config.ts (production)
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,  // JAMAIS true en production
    },
    workerOptions: {
        runInForkedProcess: false,  // Déploiement séparé en K8s
    },
    jobQueueOptions: {
        activeQueues: process.env.WORKER === 'true'
            ? undefined  // Traiter toutes les files
            : [],        // N'en traiter aucune (pod serveur uniquement)
    },
    plugins: [
        AssetServerPlugin.init({
            storageStrategyFactory: configureAssetStorage(),
        }),
        DefaultSearchPlugin.init({ bufferUpdates: true }),
        EmailPlugin.init({ /* ... */ }),
        // Tes plugins
        DataHubPlugin,
        CustomerIntelligencePlugin.init({ /* ... */ }),
    ],
};

Le paramètre critique : jobQueueOptions.activeQueues. Sur les pods serveur, mets-le à [] (tableau vide) pour qu'ils ne traitent pas les jobs en arrière-plan. Sur les pods worker, laisse-le undefined pour qu'ils traitent toutes les files. Ça sépare le traitement des requêtes du traitement en arrière-plan.

Pour en savoir plus sur notre approche du déploiement cloud et de l'architecture d'infrastructure, cette page de service couvre notre approche.

Considérations de Performance

Indexation de Base de Données

Vendure crée des index basiques sur les entités core. Pour la production, ajoute des index sur :

  • Les custom fields que tu requêtes fréquemment
  • Les colonnes d'entités de plugin utilisées dans les clauses WHERE
  • Les clés étrangères sur les grandes tables
  • Les index composites pour les patterns de requêtes courants
@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId'])  // Requête courante : avis approuvés pour un produit
@Index(['customerId', 'createdAt'])            // Requête courante : historique d'avis du client
export class CiReview extends VendureEntity {
    // ...
}

Connection Pooling

Pour PostgreSQL en production :

dbConnectionOptions: {
    type: 'postgres',
    extra: {
        max: 20,          // Max connexions par pod
        idleTimeoutMillis: 30000,
        connectionTimeoutMillis: 5000,
    },
}

Avec 4 pods serveur et 2 pods worker, ça fait 120 connexions au total. Assure-toi que le paramètre max_connections de ton PostgreSQL peut gérer ça (la valeur par défaut est 100, ce qui est trop bas).

Complexité des Requêtes GraphQL

Vendure ne limite pas la profondeur ou la complexité des requêtes GraphQL par défaut. En production, ajoute des limites pour empêcher les requêtes coûteuses de surcharger la base de données :

apiOptions: {
    shopApiPlayground: false,  // Désactiver en production
    adminApiPlayground: false,
    middleware: [{
        handler: depthLimit(10),   // Profondeur max de requête
        route: '*',
    }],
},

L'Avenir de Vendure

Vendure évolue activement. Domaines clés à surveiller :

  • Maturation du Dashboard : L'admin UI React est déjà la valeur par défaut. On peut s'attendre à plus de composants intégrés, de meilleurs points d'extension pour les plugins et des fonctionnalités de tableau de bord plus riches en standard.
  • Amélioration du B2B : Les comptes entreprise et les fonctionnalités acheteur sont sur la roadmap.
  • Meilleure Recherche : Des points d'intégration de recherche plus flexibles.
  • Vendure Cloud : Option d'hébergement managé (annoncée, pas encore largement disponible).

L'équipe core est réactive et le projet est soutenu commercialement (Vendure Ltd). L'écosystème TypeScript autour (NestJS, TypeORM, BullMQ) est mature et bien maintenu.

Pièges Courants

  1. Utiliser SQLite en production. SQLite est bien pour le développement. En production, utilise PostgreSQL. Les différences de comportement de TypeORM entre les deux bases de données causeront des bugs que tu ne verras jamais en dev.

  2. Faire tourner les workers dans le processus serveur. Sépare ton déploiement worker. Un job long dans le même processus que ton serveur API bloquera le traitement des requêtes.

  3. Pas de scoping par channel. Chaque requête doit filtrer par ctx.channelId. Oublier ça provoque des fuites de données entre les storefronts.

  4. Imports de services entre modules. Utilise l'EventBus pour la communication inter-modules. Les imports directs créent des dépendances circulaires qui cassent au fur et à mesure que le système grandit.

  5. Pas d'idempotence sur les mutations. Les retries réseau et les replays de webhooks créeront des commandes en double, des avis en double, des éléments de wishlist en double. Construis l'idempotence dès le premier jour.

  6. Noms de tables en dur. Utilise des constantes. Quand tu as plus de 30 entités réparties sur 3 plugins, les chaînes en dur deviennent un cauchemar de maintenance.

  7. Synchronize: true en production. L'auto-sync de TypeORM supprimera des colonnes et perdra des données. Utilise les migrations. Toujours.

  8. Ne pas tester avec PostgreSQL. Si tu développes sur SQLite et déploies sur PostgreSQL, tu découvriras des incompatibilités de types de colonnes, des différences de comportement transactionnel et des bugs de gestion JSON en production.

  9. Ignorer le service Worker. L'envoi d'emails, l'indexation de recherche et le traitement d'assets devraient tous passer par la file de jobs. Les faire de façon synchrone dans les handlers de requêtes rend ton API lente et peu fiable.

  10. Tout construire de zéro. Vérifie d'abord l'écosystème de plugins. Pour l'ETL/intégration de données, l'indexation de recherche et les fonctionnalités commerce courantes, des plugins existent. Construire from scratch prend 10x plus de temps.

Points Clés à Retenir

  • Le système de plugins de Vendure est le meilleur du commerce headless. Extension d'entités, extension de schéma, resolvers Shop/Admin séparés, DI NestJS complète. Aucune autre plateforme n'arrive à ce niveau pour les équipes TypeScript. L'expérience développeur est ce qui nous fait continuer à construire dessus.

  • L'EventBus permet un vrai couplage faible. Les modules communiquent uniquement par événements. Ça scale à 6+ modules dans un seul plugin sans dépendances circulaires. C'est le pattern qui rend les plugins enterprise possibles.

  • Le déploiement multi-pods nécessite une coordination explicite. Élection de leader pour les schedulers, déduplication par clé métier pour les consommateurs d'événements, idempotence pour les mutations API. Rien de tout ça n'est automatique.

  • L'admin UI React est entièrement personnalisable. L'admin basée sur React de Vendure 3 supporte les pages personnalisées, routes, composants et widgets de tableau de bord. Les développeurs de plugins obtiennent une DX moderne avec React 18, TanStack Query et le Dashboard SDK de Vendure.

  • Le multi-entrepôt et le B2B sont de vraies lacunes. Si ce sont des exigences core, évalue Medusa v2 ou Saleor. Les construire en custom sur Vendure coûte cher.

  • La production nécessite PostgreSQL, Redis et un déploiement worker séparé. SQLite est pour le développement. BullMQ est pour les jobs en arrière-plan. Sépare tes pods serveur et worker.

On prend sincèrement plaisir à construire sur Vendure. L'expérience développeur, les patterns architecturaux et la flexibilité du système de plugins en font une plateforme que l'on recommande avec confiance aux équipes TypeScript qui construisent du commerce. Le codebase reste propre même en grandissant, le framework ne te gêne pas, et la communauté est réactive et en croissance. Si tu as besoin du multi-entrepôt ou de B2B lourd prêt à l'emploi, évalue les alternatives. Pour tout le reste en commerce headless, Vendure est notre premier choix.

Explore notre guide commerce headless Vendure pour en savoir plus sur notre pratique Vendure, ou consulte les cas d'usage concrets où on a déployé Vendure en production. Si tu évalues des plateformes commerce pour ton prochain projet, parle avec notre équipe ou demande un devis.

Sujets couverts

architecture VendureVendure productionscalabilité VendureVendure vs MedusaVendure vs Saleorcommerce headless Venduredéveloppement plugin VendureVendure NestJSVendure EventBus

Prê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