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.
Beyond the Plugin Tutorial
Every Vendure tutorial shows you how to create a plugin with one entity, one resolver, and one service. That gets you from zero to "it works on my laptop." It does not get you to production with multiple pods, background workers, email deduplication, and 30+ entities across 6 modules that communicate without circular dependencies.
We've built two enterprise Vendure plugins. The Data Hub Plugin is an ETL pipeline engine with 9 extractors, 61 transform operators, and 24 entity loaders. The Customer Intelligence Plugin covers wishlists, reviews, loyalty programs, cart recovery, back-in-stock alerts, and recently viewed items. Between them, we have 50+ entities, dozens of resolvers, multiple job queues, and dashboards built with React and TanStack.
This article covers the patterns that survived production. For the platform-level architecture assessment, see our Vendure production guide. This article goes deeper into the plugin implementation patterns.
Entity Conventions
Table Name Constants
Never hardcode table names. With 30+ entities across multiple plugins, hardcoded strings become a maintenance disaster.
// shared/constants/index.ts
export const TABLE_NAMES = {
// Wishlist module
WISHLIST: 'ci_wishlist',
WISHLIST_ITEM: 'ci_wishlist_item',
// Reviews module
REVIEW: 'ci_review',
REVIEW_VOTE: 'ci_review_vote',
REVIEW_RESPONSE: 'ci_review_response',
// Loyalty module
LOYALTY_ACCOUNT: 'ci_loyalty_account',
LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
LOYALTY_TIER: 'ci_loyalty_tier',
// Cart Recovery module
ABANDONED_CART: 'ci_abandoned_cart',
RECOVERY_FLOW: 'ci_recovery_flow',
// Infrastructure
IDEMPOTENCY_KEY: 'ci_idempotency_key',
NOTIFICATION_LOG: 'ci_notification_log',
AUDIT_ENTRY: 'ci_audit_entry',
} as const;
The ci_ prefix prevents collisions with Vendure core tables and other plugins. Every entity uses the constant, never a literal string.
Entity Base Pattern
Every entity follows the same structure:
import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { DeepPartial, VendureEntity } from '@vendure/core';
import { TABLE_NAMES } from '../../shared/constants';
@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId'])
@Index(['customerId', 'createdAt'])
export class CiReview extends VendureEntity {
constructor(input?: DeepPartial<CiReview>) {
super(input);
}
@Column()
productId!: number;
@Column()
customerId!: number;
@Column()
channelId!: number;
@Column({ type: 'int', default: 0 })
rating!: number;
@Column({ type: 'text' })
body!: string;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status!: string; // pending | approved | rejected
@Column({ type: 'datetime', nullable: true })
deletedAt?: Date;
}
Rules:
- Every entity extends
VendureEntity(providesid,createdAt,updatedAt) - Every entity uses
TABLE_NAMES.Xfor the table name - Every entity includes
channelIdfor multi-channel isolation - Class names use
Ciprefix:CiReview,CiWishlist,CiLoyaltyAccount - Constructor accepts
DeepPartial<T>for clean instantiation - Indexes defined on the entity, not in migrations, for visibility
Soft Deletes
For entities that need audit trails or recovery, add deletedAt instead of hard deletes. Filter in all queries:
async findActive(ctx: RequestContext, options?: ListQueryOptions<CiReview>) {
return this.listQueryBuilder
.build(CiReview, options ?? {}, { ctx })
.andWhere('entity.deletedAt IS NULL')
.andWhere('entity.channelId = :channelId', { channelId: ctx.channelId })
.getManyAndCount()
.then(([items, totalItems]) => ({ items, totalItems }));
}
Resolver Separation
Shop API resolvers (customer-facing) and Admin API resolvers (backoffice) are always separate classes. This is not a suggestion. It's a design constraint that prevents accidental exposure of admin operations to customers.
// resolvers/review-shop.resolver.ts
@Resolver()
export class ReviewShopResolver {
constructor(private reviewService: ReviewService) {}
@Query()
@Allow(Permission.Public)
async productReviews(
@Ctx() ctx: RequestContext,
@Args() args: { productId: string; options?: ListQueryOptions<CiReview> },
) {
return this.reviewService.findApprovedByProduct(ctx, args.productId, args.options);
}
@Mutation()
@Transaction()
@Allow(Permission.Owner)
async submitReview(
@Ctx() ctx: RequestContext,
@Args() args: { input: SubmitReviewInput },
) {
return this.reviewService.submit(ctx, args.input);
}
}
// resolvers/review-admin.resolver.ts
@Resolver()
export class ReviewAdminResolver {
constructor(private reviewService: ReviewService) {}
@Query()
@Allow(Permission.ReadCatalog)
async allReviews(
@Ctx() ctx: RequestContext,
@Args() args: { options?: ListQueryOptions<CiReview> },
) {
return this.reviewService.findAll(ctx, args.options); // includes pending, rejected
}
@Mutation()
@Transaction()
@Allow(Permission.UpdateCatalog)
async moderateReview(
@Ctx() ctx: RequestContext,
@Args() args: { reviewId: string; decision: 'approve' | 'reject'; reason?: string },
) {
return this.reviewService.moderate(ctx, args.reviewId, args.decision, args.reason);
}
}
The Shop resolver exposes productReviews (approved only, public access). The Admin resolver exposes allReviews (all statuses, admin access) and moderateReview (admin only). These never share a class.
EventBus Communication
Modules communicate through the EventBus only. The wishlist module does not import the loyalty module's service. The reviews module does not import the notification module's service.
// Module A emits an event
@Injectable()
export class ReviewService {
constructor(private eventBus: EventBus) {}
async submit(ctx: RequestContext, input: SubmitReviewInput): Promise<CiReview> {
const review = await this.createReview(ctx, input);
// Emit event, don't call loyalty service directly
await this.eventBus.publish(new CiReviewSubmittedEvent(ctx, review));
return review;
}
}
// Module B subscribes and handles
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
this.eventBus.ofType(CiReviewSubmittedEvent).subscribe(async event => {
// Award loyalty points for submitting a review
await this.loyaltyService.awardPoints(
event.ctx,
event.review.customerId,
'review_submitted',
50, // points
);
});
}
}
This keeps module boundaries clean. You can remove the loyalty module without breaking the reviews module. The event is published whether anyone subscribes or not.
For how we apply EventBus patterns in non-Vendure systems, see our event-driven architecture guide.
Multi-Pod Coordination
Leader Election for Schedulers
Scheduled jobs (detect abandoned carts, expire loyalty points, check price drops) must run on exactly one pod. Without leader election, every pod runs the scheduler, and you get duplicate processing.
Vendure's JobQueue handles this. Only one worker picks up a job from the queue. Schedule jobs through the queue, not through setInterval:
@Injectable()
export class CartDetectionService implements OnModuleInit {
private detectionQueue: JobQueue<Record<string, never>>;
constructor(private jobQueueService: JobQueueService) {}
async onModuleInit() {
this.detectionQueue = await this.jobQueueService.createQueue({
name: 'ci-cart-detection',
process: async (ctx) => {
await this.detectAbandonedCarts(ctx);
},
});
}
// Called by a scheduler or cron trigger
async scheduleDetection(ctx: RequestContext) {
await this.detectionQueue.add({}, { ctx });
}
}
Notification Deduplication
When all pods consume the same event, each pod might try to send the same notification. The deduplication store prevents duplicates:
@Injectable()
export class NotificationService {
async sendIfNotDuplicate(
ctx: RequestContext,
recipient: string,
category: string,
entityRef: string,
): Promise<boolean> {
const dayBucket = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const dedupeKey = `${recipient.toLowerCase()}:${category}:${entityRef}:${dayBucket}`;
const existing = await this.connection
.getRepository(ctx, CiNotificationLog)
.findOne({ where: { dedupeKey } });
if (existing) return false; // Already sent today
await this.connection.getRepository(ctx, CiNotificationLog).save(
new CiNotificationLog({
dedupeKey,
recipient,
category,
entityRef,
channelId: ctx.channelId,
sentAt: new Date(),
}),
);
await this.emailService.send(recipient, category, entityRef);
return true;
}
}
The dedupe key includes a day bucket. The same notification can be sent again tomorrow but never twice on the same day. The unique constraint on dedupeKey prevents race conditions between pods.
GraphQL Schema Patterns
Extend Vendure's Shop and Admin APIs with standard GraphQL patterns:
export const reviewShopSchema = gql`
type CiReview implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
rating: Int!
body: String!
status: String!
customerName: String
}
type CiReviewList implements PaginatedList {
items: [CiReview!]!
totalItems: Int!
}
input SubmitReviewInput {
productId: ID!
rating: Int!
body: String!
}
extend type Query {
productReviews(productId: ID!, options: CiReviewListOptions): CiReviewList!
}
extend type Mutation {
submitReview(input: SubmitReviewInput!): CiReview!
}
input CiReviewListOptions
`;
// Auto-generated sort/filter options via Vendure's ListQueryBuilder
Vendure's ListQueryBuilder automatically generates sort and filter options for any entity that implements PaginatedList. You get filtering by any column and sorting for free.
Dashboard Development (React)
Vendure 3's React-based admin UI provides extension points for plugin dashboards:
// dashboard/routes.ts
import { DashboardRouteDefinition } from '@vendure/dashboard';
import { ReviewListPage } from './pages/ReviewListPage';
import { ReviewDetailPage } from './pages/ReviewDetailPage';
export const routes: DashboardRouteDefinition[] = [
{
path: 'reviews',
component: ReviewListPage,
navMenuItem: {
id: 'ci-reviews',
label: 'Reviews',
icon: 'star',
section: 'marketing',
},
},
{
path: 'reviews/:id',
component: ReviewDetailPage,
},
];
Dashboard pages use TanStack Query for data fetching and Vendure's Dashboard SDK components:
// dashboard/pages/ReviewListPage.tsx
import { Page, PageTitle, PageBlock } from '@vendure/dashboard';
import { useQuery } from '@tanstack/react-query';
import { graphql } from '../codegen';
const ALL_REVIEWS_QUERY = graphql(`
query AllReviews($options: CiReviewListOptions) {
allReviews(options: $options) {
items { id rating body status createdAt }
totalItems
}
}
`);
export function ReviewListPage() {
const { data, isLoading } = useQuery({
queryKey: ['reviews'],
queryFn: () => adminClient.query(ALL_REVIEWS_QUERY),
});
return (
<Page>
<PageTitle>Reviews</PageTitle>
<PageBlock>
{/* Render review list with moderation controls */}
</PageBlock>
</Page>
);
}
For our Vendure production architecture guide, that covers the broader platform assessment including the React admin UI capabilities.
Testing
Unit Tests
Test service methods with mocked dependencies:
describe('ReviewService', () => {
it('should reject review with rating > 5', async () => {
const service = createTestService();
await expect(
service.submit(mockCtx, { productId: '1', rating: 6, body: 'Great' }),
).rejects.toThrow('Rating must be between 1 and 5');
});
});
E2E Tests
Test the full GraphQL API with a real Vendure server:
import { createTestEnvironment } from '@vendure/testing';
describe('Review Shop API', () => {
const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
beforeAll(async () => {
await server.init({ initialData, productsCsvPath: './test-data/products.csv' });
await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
});
afterAll(() => server.destroy());
it('should submit a review', async () => {
const { submitReview } = await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 5, body: 'Excellent product' },
});
expect(submitReview.status).toBe('pending');
});
it('should not allow unauthenticated reviews', async () => {
await shopClient.asAnonymousUser();
const result = await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 5, body: 'Test' },
});
expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
it('should only show approved reviews publicly', async () => {
// Submit review (pending)
await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
await shopClient.query(SUBMIT_REVIEW, {
input: { productId: '1', rating: 4, body: 'Good' },
});
// Public query should not show pending reviews
await shopClient.asAnonymousUser();
const { productReviews } = await shopClient.query(PRODUCT_REVIEWS, { productId: '1' });
expect(productReviews.items.every(r => r.status === 'approved')).toBe(true);
});
});
Vendure's createTestEnvironment spins up a real server with SQLite, runs migrations, seeds test data, and provides authenticated clients for both APIs. Tests run against the actual GraphQL surface.
For broader testing patterns, see our software engineering guide.
Common Pitfalls
-
Hardcoded table names. Use constants from
shared/constants. With 30+ entities, hardcoded strings guarantee typos and inconsistencies. -
Mixed Shop/Admin resolvers. Separate classes. Always. A single resolver class with both public and admin queries is a security incident waiting to happen.
-
Cross-module service imports. Use the EventBus. If module A imports module B's service, you've created a coupling that breaks when either module changes.
-
No channel scoping. Every query must filter by
ctx.channelId. Missing this leaks data between storefronts. -
setInterval for scheduling. Use Vendure's JobQueue.
setIntervalruns on every pod, has no failure recovery, and no observability. -
No notification deduplication. Without a dedupe store, every pod that receives an event sends the same email. Customers get 3 copies of every notification.
-
Testing with SQLite only. TypeORM behaves differently on SQLite and PostgreSQL. Column types, JSON handling, and transaction isolation all differ. Test with PostgreSQL before shipping.
-
No idempotency on mutations. Network retries create duplicate wishlists, duplicate reviews, duplicate loyalty transactions. Build idempotency from day one.
Key Takeaways
-
Entity conventions prevent chaos at scale. Table name constants,
Ciprefix,VendureEntitybase class, channel scoping, and soft deletes. These patterns are boring but essential when you have 30+ entities. -
Resolver separation is a security boundary. Shop resolvers expose public/owner data. Admin resolvers expose management operations. Never mix them.
-
EventBus is the only inter-module communication. No cross-module service imports. Events keep modules independent and removable.
-
Multi-pod coordination is not automatic. Leader election for schedulers, deduplication for notifications, idempotency for mutations. Plan for multiple pods from day one.
-
Test with a real Vendure server.
createTestEnvironmentgives you a real server with real GraphQL. Test permissions, data isolation, and business rules against the actual API surface.
We build enterprise Vendure plugins as part of our ecommerce practice. If you're building a complex Vendure plugin or need help with plugin architecture, talk to our team or request a quote. See also our Vendure headless commerce guide for more on our Vendure work.
Topics covered
Related Guides
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.
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