Vendure in Production: Strengths, Gaps, and When to Choose It
An honest architecture assessment of Vendure for production commerce. Plugin system, EventBus, Worker service, AdminUI, multi-pod deployment, and comparison with Medusa and Saleor.
Why We Chose Vendure (and When We Wouldn't)
We've been building on Vendure since its early versions, and we genuinely love working with it. The developer experience is outstanding. You write TypeScript everywhere, NestJS dependency injection makes services clean and testable, the plugin system lets you extend anything without forking, and the GraphQL API is well-designed from day one. As a team of architects and senior engineers, we care deeply about DX, and Vendure delivers it better than any commerce platform we've worked with.
That's not marketing. It's what happens when you build two enterprise plugins on a framework and everything just works the way you expect. One is a full ETL pipeline system (Vendure Data Hub Plugin) with 9 extractors, 61 transform operators, and 24 entity loaders. The other is a 6-module customer intelligence suite covering wishlists, reviews, loyalty programs, cart recovery, back-in-stock alerts, and recently viewed items. We chose Vendure for these because the plugin architecture gave us the flexibility to build complex systems without fighting the framework. The codebase stays clean, the patterns stay consistent, and refactoring is safe because TypeScript catches everything at compile time.
That said, Vendure is not perfect for every use case. This article is an honest assessment from a system architecture perspective. If you're a CTO evaluating commerce platforms, a lead engineer planning a build, or an architect designing a multi-channel system, this is what you need to know. For broader context on how we approach ecommerce platform decisions, that guide covers the full landscape.
Architecture Overview
Vendure is a headless commerce framework built on NestJS (Node.js), TypeORM, and GraphQL. The core provides products, orders, customers, payments, shipping, promotions, and a plugin system to extend everything.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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 β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
What Vendure Does Brilliantly
1. The Plugin System
This is Vendure's strongest differentiator. A plugin can extend every layer of the system: entities, services, resolvers, GraphQL schema, admin UI, workers, and event handlers. All without modifying core code.
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) => {
// Modify core config if needed
return config;
},
})
export class WishlistPlugin {}
What makes it genuinely good:
- Entity extension: Add new TypeORM entities that get their own tables, migrations, and relations
- Schema extension: Extend the Shop API and Admin API GraphQL schemas independently
- Resolver separation: Shop API resolvers (customer-facing) and Admin API resolvers (backoffice) are always separate classes. This is a design constraint that prevents accidental exposure of admin operations to customers
- Service injection: Full NestJS dependency injection. Your plugin services can inject Vendure core services.
- Custom fields: Add fields to any core entity (Product, Customer, Order) without touching Vendure source code
We built two enterprise plugins using this system. The Data Hub Plugin adds a complete ETL pipeline engine with 9 data extractors, 61 transform operators, and 24 entity loaders. The Customer Intelligence Plugin adds 6 customer engagement modules with their own entities, GraphQL APIs, admin dashboards, and background jobs. Both run in production without modifying a single line of Vendure core.
2. The EventBus
Vendure's EventBus is the backbone of loose coupling between modules. Every significant action in the system emits an event:
// Vendure core emits events automatically
ProductEvent // product created/updated/deleted
OrderStateTransitionEvent // order state changes
CustomerEvent // customer created/updated
OrderPlacedEvent // order successfully placed
RefundStateTransitionEvent // refund status changes
// Your plugins emit custom events
export class CiWishlistItemAddedEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public wishlistId: string,
public productVariantId: string,
) {
super();
}
}
// Other modules subscribe without importing the emitting module
@Injectable()
export class StockSubscriber {
constructor(private eventBus: EventBus) {
this.eventBus.ofType(OrderPlacedEvent).subscribe(event => {
// Update stock, notify warehouse, etc.
});
}
}
The key architectural rule: modules communicate through the EventBus only. No cross-module service imports. This prevents circular dependencies and keeps modules independently deployable. When we built the Customer Intelligence Plugin with 6 modules (wishlist, reviews, loyalty, cart recovery, back-in-stock, recently viewed), each module only knows about its own services. Cross-module coordination (e.g., loyalty points for a review) happens through events.
3. TypeScript End-to-End (The DX That Keeps Us Here)
This is where Vendure really shines as a developer experience. The entire stack is TypeScript: server, plugins, admin UI, GraphQL code generation. Everything is typed, everything compiles, everything catches errors before runtime.
- Type-safe API contracts from database entity to GraphQL schema to frontend component
- GraphQL schema generates TypeScript types automatically with codegen
- Rename a field in your entity, and the compiler shows you every place that needs updating
- One language for backend services, plugin development, admin dashboard, and test suites
- NestJS decorators (
@Injectable(),@Transaction(),@Allow()) make intent explicit in the code RequestContextflows through every service method, carrying auth, channel, and locale context
This is not a small thing. In PHP-based commerce platforms (Magento, Sylius, even Pimcore's commerce layer), you lose type safety at every boundary. In Vendure, a breaking change in your product entity surfaces as a compile error in your storefront query. That feedback loop is the difference between spending 2 minutes fixing a type mismatch and spending 2 hours debugging a production bug.
The flexibility is also remarkable. Need a custom checkout flow? Write a service. Need to modify how promotions apply? Override the promotion strategy. Need a completely new entity with its own GraphQL API, admin dashboard page, and background job? The plugin system handles all of it with clean patterns that stay consistent as the system grows. We've never felt constrained by the framework, and that's rare for commerce platforms.
4. Worker Service (BullMQ)
Vendure separates long-running operations into a dedicated Worker service backed by BullMQ (Redis):
// Define a job queue in your 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);
},
});
// Enqueue from anywhere
await this.myJobQueue.add({ productId: '123' }, { ctx });
The Worker service runs as a separate process. In Kubernetes, it's a separate deployment with independent scaling. This cleanly separates request handling (web pods) from background processing (worker pods).
For how we think about event-driven patterns and background job architectures, our engineering guide covers the broader principles.
5. GraphQL API Design
Vendure provides two separate GraphQL APIs:
- Shop API: Customer-facing operations (browse products, place orders, manage account)
- Admin API: Backoffice operations (manage products, process orders, configure system)
Both support:
- Automatic pagination with
ListQueryBuilder - Built-in filtering and sorting
- Field-level permissions
- Custom fields on any entity
// ListQueryBuilder generates efficient SQL with pagination, sorting, filtering
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. The React Admin UI (Vendure 3)
Vendure 3 ships with a fully React-based admin UI, designed for extensibility from the ground up. This replaced the old Angular admin from Vendure 1/2 and is a major step forward.
Plugin developers can:
- Add custom pages and routes to the admin dashboard
- Extend existing views with custom components
- Build full dashboard experiences using React, TanStack Query, and TanStack Router
- Use Vendure's Dashboard SDK components (Page, PageTitle, DetailFormGrid, ListPage)
- Register custom menu items, widgets, and detail tabs
We've built our plugin dashboards using this stack. The DX is solid and feels natural for any React developer.
7. Strategies and Extension Points
Vendure provides strategy interfaces for customizing core commerce behavior without modifying source code:
// Custom promotion condition
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;
}
}
// Custom shipping calculator
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 };
}
}
Tax calculation, payment processing, order fulfillment, asset storage, and search indexing all have strategy interfaces. This means you can change how VAT is calculated for a specific country, how payments are captured, or how orders are fulfilled without touching core code. The framework provides the hooks, you provide the business logic.
8. Custom Fields on Core Entities
One of Vendure's most pragmatic features. Add fields to any core entity without migrations or core modifications:
// In your plugin configuration
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' }] },
],
}
Custom fields automatically appear in the admin UI, are included in GraphQL schemas, and are queryable via the API. They're stored in the same table as the core entity, so joins are free. You can filter and sort by custom fields using Vendure's standard ListQueryBuilder. This is how you bridge Vendure with external systems: the erpId on Product becomes the join key for ERP sync, the erpCustomerId on Customer links to your CRM, and the erpOrderId on Order tracks which orders have been exported.
Custom fields also support relations to other entities, localized strings (translated per language), and validation hooks. They're the primary mechanism for adapting Vendure to your domain without separate extension tables.
9. Custom Pricing Strategies
Vendure's pricing system is strategy-based. The default calculates price from the product variant's stored price. But in enterprise commerce, pricing is rarely that simple. You might need prices from an ERP, customer-specific discounts, volume-based tiers, or real-time price calculations.
// Custom pricing strategy that fetches prices from an external ERP
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> {
// Check if variant has an ERP SKU
const erpSku = productVariant.customFields?.supplierSku;
if (!erpSku) {
// Fall back to Vendure's stored price
return {
price: productVariant.listPrice,
priceIncludesTax: productVariant.listPriceIncludesTax,
};
}
// Fetch real-time price from 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,
};
}
}
Register the strategy in your Vendure config:
orderOptions: {
orderItemPriceCalculationStrategy: new ErpPricingStrategy(),
}
This is how enterprise Vendure installations handle customer-specific pricing, contract prices, and dynamic price calculations. The strategy receives the full order context (customer, quantity, currency) so you can implement any pricing logic your business requires. You can also combine this with Vendure's built-in promotion system for stacking discounts on top of ERP-sourced base prices.
10. Custom GraphQL Queries for External System Integration
One of Vendure's strongest patterns for enterprise integration: you can add custom GraphQL queries and mutations that pull data from any external system and expose it through Vendure's API. This turns Vendure into a unified commerce API layer that aggregates data from ERPs, PIMs, warehouses, and other backends.
// Schema extension: add a query that fetches real-time stock from an 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: fetch from ERP API
@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: handles the actual ERP API calls with caching and error handling
@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 cache
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), // convert to cents
currency: ctx.currencyCode,
priceListName: response.data.priceList,
validUntil: response.data.validUntil,
};
} catch (error) {
// ERP unavailable: return null, let frontend fall back to catalog price
return null;
}
}
}
The storefront then queries Vendure for both catalog data and ERP data through a single GraphQL endpoint:
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
}
}
One request. Catalog data from Vendure's database, stock from the ERP, customer-specific pricing from the ERP. The storefront doesn't need to know about the ERP. It just queries Vendure. This pattern works with any external system: REST APIs, SOAP services, GraphQL endpoints, gRPC backends. Vendure becomes the composition layer.
For how we build these integration pipelines at scale (scheduled syncs, webhook listeners, bulk imports), the Data Hub Plugin handles the full ETL side. The custom GraphQL queries handle the real-time, per-request side.
Where Vendure Has Gaps
1. No Built-in Multi-Warehouse
Vendure has a single stock location model. If you need multi-warehouse inventory (stock in warehouse A, different stock in warehouse B, fulfillment routing by location), you need to build it yourself or use a third-party plugin.
This is a significant gap for any commerce operation with more than one physical location. The workaround (custom fields + custom stock allocation logic) is fragile and doesn't integrate with Vendure's built-in order fulfillment flow.
2. Limited B2B Features
Vendure is primarily designed for B2C commerce. B2B requirements like:
- Company accounts with multiple buyers
- Approval workflows for orders
- Customer-specific pricing with complex rules
- Quote management
- Purchase orders
- Budget controls per buyer
All require custom development. The Channel system provides some multi-tenant capability, but it's not a B2B feature set.
3. Search Is Basic
Vendure's built-in search uses a database-backed full-text index. It works for small catalogs but doesn't scale for:
- Faceted search with complex filters
- Typo tolerance
- Synonym handling
- Multilingual search with language-specific analyzers
- Real-time index updates
For production search, you need an external engine (MeiliSearch, Elasticsearch, Algolia). Our Data Hub Plugin includes search sinks for MeiliSearch, Elasticsearch, OpenSearch, Algolia, and Typesense with real-time indexing via Vendure events. For more on search architecture in commerce, that guide covers the technical details.
4. Migration Tooling
TypeORM migrations in Vendure can be fragile. When multiple plugins define entities, migration generation order becomes unpredictable. We've encountered:
- Migrations that reference tables not yet created by other plugin migrations
- Column type mismatches between SQLite (dev) and PostgreSQL (prod)
- Migration conflicts when two plugins modify the same core entity via custom fields
The workaround: generate migrations per plugin, test with PostgreSQL (not SQLite), and maintain a strict migration ordering in your deployment scripts. We handle similar migration challenges in our Pimcore upgrade guide, where database schema management is even more complex.
Entity Architecture Patterns
After building two enterprise plugins, these patterns emerged as non-negotiable for production quality.
Entity Prefix Convention
Every plugin entity uses a prefix to prevent naming collisions:
// Table names defined in constants (never hardcoded)
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',
// ...
};
// Entity uses the constant
@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;
}
Every entity extends VendureEntity (provides id, createdAt, updatedAt). Every entity includes channelId for multi-channel isolation. Every table name comes from a shared constant, never hardcoded strings.
Soft Deletes
For entities that need audit trails, use a deletedAt column instead of hard deletes:
@Column({ type: 'datetime', nullable: true })
deletedAt?: Date;
This preserves referential integrity and allows recovery. Filter deleted records in all queries by default.
Channel Scoping
Every query must scope to the active channel. Vendure's RequestContext carries the channel information:
async findByCustomer(ctx: RequestContext, customerId: number) {
return this.connection.getRepository(ctx, CiWishlist).find({
where: {
customerId,
channelId: ctx.channelId,
},
});
}
Without channel scoping, data from one storefront leaks into another. This is the most common security bug in multi-channel Vendure deployments. We cover similar multi-tenant data isolation patterns in our AI governance guide and our trust page.
Testing Patterns
Vendure supports both unit and e2e testing for plugins. The testing setup is one of the framework's underappreciated strengths.
Unit Tests
Test service methods with mocked repositories:
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();
});
});
E2E Tests
Test the full GraphQL API with a real Vendure server:
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');
});
});
Vendure's createTestEnvironment spins up a real server with SQLite, runs migrations, seeds test data, and provides authenticated GraphQL clients for both Shop and Admin APIs. Tests run against the actual API surface, not mocks. This catches permission issues, schema mismatches, and data validation bugs that unit tests miss.
We use Vitest with SWC for fast unit tests and Vitest with forked processes for e2e tests (necessary because Vendure's server lifecycle requires process isolation between test suites). For how we approach testing and quality in our broader engineering practice, see our methodology page.
Multi-Pod Coordination
Running Vendure across multiple pods introduces coordination challenges that don't exist in single-instance deployments.
Leader Election for Schedulers
Scheduled jobs (cart detection every 15 minutes, loyalty expiry daily, price drop checks hourly) must run on exactly one instance. If all pods run the scheduler, you get duplicate processing.
// Scheduler services use leader election via Vendure's JobQueue
// Only one instance processes the 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);
},
});
// Schedule: runs every 15 minutes, but only on one pod
await this.jobQueue.add({}, { ctx: RequestContext.empty() });
}
}
Business-Key Deduplication for Event Consumers
Event consumers (price drop notifications, stock alerts, review requests) run on all pods. Each pod's EventBus subscription receives the event. Deduplication prevents sending duplicate emails:
| Service Type | Multi-Instance Behavior |
|---|---|
| Schedulers (cart detection, loyalty expiry) | Leader election via DB lock. Only 1 instance runs. |
| Event consumers (price drops, stock alerts) | All instances consume. Idempotent via business-key dedup. |
// Business-key deduplication for notifications
const dedupeKey = `${recipientEmail}:${category}:${entityRef}:${dayBucket}`;
const existing = await this.notificationRepo.findOne({ where: { dedupeKey } });
if (existing) {
return; // Already sent today
}
The dedupe key includes a dayBucket (UTC date YYYY-MM-DD) so the same notification can be sent again the next day, but never twice on the same day.
Idempotency for API Mutations
Customer-facing mutations (add to wishlist, submit review, redeem loyalty points) need idempotency to handle retries and network failures:
@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 of normalized input
@Column({ type: 'varchar', length: 20 })
status!: string; // PENDING, COMPLETED, FAILED
}
// Unique constraint: UNIQUE(scope, key)
Two distinct idempotency models:
- API idempotency (CiIdempotencyKey): For user-initiated mutations. Key provided by client or generated from input hash. Replays cached response on duplicate.
- Job idempotency (queue-level): For background processing. Dedupe key in job payload. Checked by worker before execution. Uses DB constraints or completion markers.
Never conflate these two models. They have different lifecycles and different failure semantics.
For how we handle similar concurrency patterns across our systems, see our guide on AI workflow design which covers related orchestration challenges.
Data Hub: ETL for Vendure
One of the biggest gaps in headless commerce is data integration. How do you get products from your ERP into Vendure? How do you sync inventory across channels? How do you generate product feeds for Google Merchant Center?
We built the Vendure Data Hub Plugin to solve this. It's a full ETL pipeline engine that runs inside Vendure:
| Component | Count | Examples |
|---|---|---|
| Extractors | 9 | HTTP/REST, GraphQL, Database (SQL), File (CSV/JSON/XML), S3, FTP/SFTP, Webhook, CDC |
| Transform Operators | 61 | String (12), Date (5), Numeric (9), Logic (4), JSON (4), Data (8), Enrichment (5), Aggregation (8), Validation (2) |
| Entity Loaders | 24 | Products, Variants, Customers, Collections, Orders, Promotions, Assets, Facets |
| Feed Generators | 4 | Google Merchant Center, Meta Catalog, Amazon Seller Central, Custom |
| Search Sinks | 7 | Elasticsearch, OpenSearch, MeiliSearch, Algolia, Typesense, Queue Producers, Webhooks |
| Triggers | 6 | Manual, Scheduled (cron), Webhook, Vendure Events, File Watch, Message Queue |
The plugin uses the same patterns as Vendure core: TypeORM entities, NestJS services, GraphQL API, EventBus integration. It includes a visual pipeline editor in the admin dashboard, real-time execution logs, checkpoint recovery (resume from last successful record), and distributed locks for multi-pod safety.
This is the kind of data engineering infrastructure that enterprise commerce needs but rarely gets from the commerce platform itself.
Vendure vs Medusa vs Saleor (2026)
An honest comparison for architects evaluating platforms:
| Criteria | Vendure | Medusa v2 | Saleor |
|---|---|---|---|
| Language | TypeScript (NestJS) | TypeScript (custom framework) | Python (Django + GraphQL) |
| API | GraphQL (Shop + Admin) | REST + JS SDK + Admin API | GraphQL |
| Plugin system | Excellent (entities, schema, resolvers, admin) | Good (modules, workflows) | Limited (apps via webhooks) |
| Database | PostgreSQL, MySQL, SQLite (TypeORM) | PostgreSQL (MikroORM) | PostgreSQL (Django ORM) |
| Admin UI | React (built-in, fully customizable) | React (built-in) | React (built-in, Dashboard) |
| Worker/Jobs | BullMQ (built-in) | Built-in job system | Celery (Python) |
| Multi-channel | Channels (built-in) | Sales Channels | Channels |
| B2B features | Limited (custom dev needed) | Growing (some built-in) | Growing (some built-in) |
| Search | Basic (DB-backed) | Basic (needs integration) | Basic (needs integration) |
| Multi-warehouse | Not built-in | Built-in (v2) | Built-in |
| Community | Medium (growing) | Large (fast-growing) | Medium |
| Hosting | Self-hosted | Self-hosted + Medusa Cloud | Saleor Cloud + self-hosted |
| Maturity | Stable, production-proven | v2 is newer, API changes possible | Stable, production-proven |
| DX for TypeScript teams | Excellent | Good | Requires Python knowledge |
| Enterprise plugins | Strong ecosystem | Growing | Webhook-based (limited) |
When to Choose Vendure
- Your team writes TypeScript. The DX advantage is not marginal, it's transformative. Type safety across the entire commerce stack changes how fast you can ship and how confidently you can refactor.
- You need deep plugin extensibility. No other platform lets you add entities, extend the GraphQL schema, build admin dashboard pages, and register background jobs from a single plugin declaration.
- You're building B2C or B2C-heavy hybrid commerce with custom business logic. Vendure gives you a solid commerce core and gets out of your way for the rest.
- You want to own your infrastructure. Vendure runs anywhere Node.js runs.
- You need tight integration with existing NestJS microservices or TypeScript backends. Vendure is already NestJS, so your services speak the same language.
- You value clean architecture and long-term maintainability. The patterns (EventBus, RequestContext, separate Shop/Admin resolvers) scale to large codebases without decay.
When to Choose Medusa
- You need multi-warehouse out of the box.
- You prefer REST over GraphQL for your storefront.
- You want a managed cloud option with the open-source flexibility.
- You're starting fresh and want the newest framework patterns.
When to Choose Saleor
- Your team is Python/Django.
- You need the managed cloud offering (Saleor Cloud).
- You want strong built-in internationalization and multi-currency.
- You need marketplace features.
When to Choose Shopify
- You don't want to manage infrastructure.
- Your commerce needs are standard (catalog, checkout, payments).
- You need the largest app ecosystem.
- You're willing to accept the platform's constraints for faster time-to-market.
For our broader perspective on headless commerce platforms and where the industry is heading, that guide covers the full landscape.
Production Deployment Architecture
Recommended Stack
| Component | Technology | Purpose |
|---|---|---|
| Server | Vendure (NestJS) | Commerce API |
| Database | PostgreSQL 15+ | Primary data store |
| Cache | Redis 7+ | Sessions, cache, job queues |
| Search | MeiliSearch or OpenSearch | Product search, faceted filtering |
| Storage | S3 / Azure Blob / local | Asset storage |
| CDN | CloudFront / Cloudflare | Asset delivery |
| Message broker | Redis (BullMQ) or RabbitMQ | Job queues, event processing |
| Monitoring | OpenTelemetry + Grafana | Observability |
Kubernetes Pod Architecture
| Pod | Purpose | Replicas |
|---|---|---|
vendure-server | Shop API + Admin API | 2-4 |
vendure-worker | BullMQ job processing | 1-3 |
postgres | Database (or managed) | 1+ |
redis | Cache + queues | 1+ |
meilisearch | Search engine | 1-2 |
Environment Configuration
// 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, // NEVER true in production
},
workerOptions: {
runInForkedProcess: false, // Separate deployment in K8s
},
jobQueueOptions: {
activeQueues: process.env.WORKER === 'true'
? undefined // Process all queues
: [], // Don't process any (server-only pod)
},
plugins: [
AssetServerPlugin.init({
storageStrategyFactory: configureAssetStorage(),
}),
DefaultSearchPlugin.init({ bufferUpdates: true }),
EmailPlugin.init({ /* ... */ }),
// Your plugins
DataHubPlugin,
CustomerIntelligencePlugin.init({ /* ... */ }),
],
};
The critical setting: jobQueueOptions.activeQueues. On server pods, set it to [] (empty array) so they don't process background jobs. On worker pods, leave it undefined so they process all queues. This separates request handling from background processing.
For more on how we think about cloud deployment and infrastructure architecture, that service page covers our approach.
Performance Considerations
Database Indexing
Vendure creates basic indexes on core entities. For production, add indexes on:
- Custom fields you query frequently
- Plugin entity columns used in WHERE clauses
- Foreign keys on large tables
- Composite indexes for common query patterns
@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId']) // Common query: approved reviews for a product
@Index(['customerId', 'createdAt']) // Common query: customer's review history
export class CiReview extends VendureEntity {
// ...
}
Connection Pooling
For PostgreSQL in production:
dbConnectionOptions: {
type: 'postgres',
extra: {
max: 20, // Max connections per pod
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
},
}
With 4 server pods and 2 worker pods, that's 120 total connections. Make sure your PostgreSQL max_connections setting can handle this (default is 100, which is too low).
GraphQL Query Complexity
Vendure doesn't limit GraphQL query depth or complexity by default. In production, add limits to prevent expensive queries from overloading the database:
apiOptions: {
shopApiPlayground: false, // Disable in production
adminApiPlayground: false,
middleware: [{
handler: depthLimit(10), // Max query depth
route: '*',
}],
},
The Future of Vendure
Vendure is actively evolving. Key areas to watch:
- Dashboard Maturation: The React admin UI is already the default. Expect more built-in components, better plugin extension points, and richer out-of-the-box dashboard features.
- Improved B2B: Company accounts and buyer features are on the roadmap.
- Better Search: More flexible search integration points.
- Vendure Cloud: Managed hosting option (announced, not yet widely available).
The core team is responsive and the project is commercially backed (Vendure Ltd). The TypeScript ecosystem around it (NestJS, TypeORM, BullMQ) is mature and well-maintained.
Common Pitfalls
-
Using SQLite in production. SQLite is fine for development. In production, use PostgreSQL. TypeORM behavior differences between the two databases will cause bugs you never see in dev.
-
Running workers in the server process. Separate your worker deployment. A long-running job in the same process as your API server will block request handling.
-
No channel scoping. Every query must filter by
ctx.channelId. Missing this leaks data between storefronts. -
Cross-module service imports. Use the EventBus for inter-module communication. Direct imports create circular dependencies that break as the system grows.
-
No idempotency on mutations. Network retries and webhook replays will create duplicate orders, duplicate reviews, duplicate wishlist items. Build idempotency from day one.
-
Hardcoded table names. Use constants. When you have 30+ entities across 3 plugins, hardcoded strings become a maintenance nightmare.
-
Synchronize: true in production. TypeORM's auto-sync will drop columns and lose data. Use migrations. Always.
-
Not testing with PostgreSQL. If you develop on SQLite and deploy to PostgreSQL, you will discover column type mismatches, transaction behavior differences, and JSON handling bugs in production.
-
Ignoring the Worker service. Email sending, search indexing, and asset processing should all go through the job queue. Doing them synchronously in request handlers makes your API slow and unreliable.
-
Building everything custom. Check the plugin ecosystem first. For ETL/data integration, search indexing, and common commerce features, plugins exist. Building from scratch takes 10x longer.
Key Takeaways
-
Vendure's plugin system is the best in headless commerce. Entity extension, schema extension, separate Shop/Admin resolvers, full NestJS DI. No other platform comes close for TypeScript teams. The developer experience is what keeps us building on it.
-
The EventBus enables true loose coupling. Modules communicate through events only. This scales to 6+ modules in a single plugin without circular dependencies. It's the pattern that makes enterprise plugins possible.
-
Multi-pod deployment requires explicit coordination. Leader election for schedulers, business-key deduplication for event consumers, idempotency for API mutations. None of this is automatic.
-
The React admin UI is fully customizable. Vendure 3's React-based admin supports custom pages, routes, components, and dashboard widgets. Plugin developers get a modern DX with React 18, TanStack Query, and Vendure's Dashboard SDK.
-
Multi-warehouse and B2B are genuine gaps. If these are core requirements, evaluate Medusa v2 or Saleor. Building them custom on Vendure is expensive.
-
Production requires PostgreSQL, Redis, and separate worker deployment. SQLite is for development. BullMQ is for background jobs. Separate your server and worker pods.
We genuinely enjoy building on Vendure. The developer experience, the architectural patterns, and the flexibility of the plugin system make it a platform we recommend with confidence for TypeScript teams building commerce. The codebase stays clean even as it grows, the framework stays out of your way, and the community is responsive and growing. If you need multi-warehouse or heavy B2B out of the box, evaluate alternatives. For everything else in headless commerce, Vendure is our first choice.
Explore our Vendure headless commerce guide for more on our Vendure practice, or see real-world use cases where we've deployed Vendure in production. If you're evaluating commerce platforms for your next project, talk to our team or request a quote.
Topics covered
Related Guides
Building Enterprise Vendure Plugins: Patterns That Survive Multi-Pod Deployment
Production patterns for Vendure plugin development. Entity conventions, resolver separation, EventBus communication, leader election, notification deduplication, and testing with real servers.
Read guideEnterprise Guide to Agentic AI Systems
Technical guide to agentic AI systems in enterprise environments. Learn the architecture, capabilities, and applications of autonomous AI agents.
Read guideAgentic Commerce: How to Let AI Agents Buy Things Safely
How to design governed AI agent-initiated commerce. Policy engines, HITL approval gates, HMAC receipts, idempotency, tenant scoping, and the full Agentic Checkout Protocol.
Read guideReady to build production AI systems?
Our team specializes in building production-ready AI systems. Let's discuss how we can help transform your enterprise with cutting-edge technology.
Start a conversation