Technical Guide

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.

February 8, 202618 min readOronts Engineering Team

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 (provides id, createdAt, updatedAt)
  • Every entity uses TABLE_NAMES.X for the table name
  • Every entity includes channelId for multi-channel isolation
  • Class names use Ci prefix: 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

  1. Hardcoded table names. Use constants from shared/constants. With 30+ entities, hardcoded strings guarantee typos and inconsistencies.

  2. Mixed Shop/Admin resolvers. Separate classes. Always. A single resolver class with both public and admin queries is a security incident waiting to happen.

  3. 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.

  4. No channel scoping. Every query must filter by ctx.channelId. Missing this leaks data between storefronts.

  5. setInterval for scheduling. Use Vendure's JobQueue. setInterval runs on every pod, has no failure recovery, and no observability.

  6. No notification deduplication. Without a dedupe store, every pod that receives an event sends the same email. Customers get 3 copies of every notification.

  7. 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.

  8. 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, Ci prefix, VendureEntity base 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. createTestEnvironment gives 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

Vendure plugin developmentVendure custom pluginVendure NestJSVendure entityVendure EventBusVendure multi-podVendure testingVendure enterprise plugin

Ready 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