Technischer Leitfaden

Enterprise Vendure Plugins entwickeln: Patterns, die Multi-Pod-Deployment überleben

Produktionserprobte Patterns für Vendure Plugin-Entwicklung. Entity-Konventionen, Resolver-Trennung, EventBus-Kommunikation, Leader Election, Benachrichtigungs-Deduplizierung und Testing mit echten Servern.

8. Februar 202618 Min. LesezeitOronts Engineering Team

Jenseits des Plugin-Tutorials

Jedes Vendure-Tutorial zeigt dir, wie du ein Plugin mit einer Entity, einem Resolver und einem Service erstellst. Damit kommst du von Null auf "läuft auf meinem Laptop." Es bringt dich aber nicht in die Produktion mit mehreren Pods, Background Workers, E-Mail-Deduplizierung und 30+ Entities über 6 Module hinweg, die ohne zirkuläre Abhängigkeiten kommunizieren.

Wir haben zwei Enterprise-Vendure-Plugins gebaut. Das Data Hub Plugin ist eine ETL-Pipeline-Engine mit 9 Extractors, 61 Transform-Operatoren und 24 Entity-Loadern. Das Customer Intelligence Plugin deckt Wunschlisten, Bewertungen, Treueprogramme, Cart Recovery, Back-in-Stock-Alerts und kürzlich angesehene Artikel ab. Zusammen haben wir 50+ Entities, Dutzende Resolver, mehrere Job Queues und Dashboards mit React und TanStack.

Dieser Artikel behandelt die Patterns, die in der Produktion überlebt haben. Für die Bewertung der Plattformarchitektur siehe unseren Vendure-Produktionsleitfaden. Dieser Artikel geht tiefer in die Plugin-Implementierungsmuster ein.

Entity-Konventionen

Table-Name-Konstanten

Hardcode niemals Tabellennamen. Mit 30+ Entities über mehrere Plugins hinweg werden hardcodierte Strings zum Wartungs-Albtraum.

// shared/constants/index.ts
export const TABLE_NAMES = {
    // Wunschlisten-Modul
    WISHLIST: 'ci_wishlist',
    WISHLIST_ITEM: 'ci_wishlist_item',

    // Bewertungs-Modul
    REVIEW: 'ci_review',
    REVIEW_VOTE: 'ci_review_vote',
    REVIEW_RESPONSE: 'ci_review_response',

    // Treue-Modul
    LOYALTY_ACCOUNT: 'ci_loyalty_account',
    LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
    LOYALTY_TIER: 'ci_loyalty_tier',

    // Cart-Recovery-Modul
    ABANDONED_CART: 'ci_abandoned_cart',
    RECOVERY_FLOW: 'ci_recovery_flow',

    // Infrastruktur
    IDEMPOTENCY_KEY: 'ci_idempotency_key',
    NOTIFICATION_LOG: 'ci_notification_log',
    AUDIT_ENTRY: 'ci_audit_entry',
} as const;

Das ci_-Prefix verhindert Kollisionen mit Vendure-Core-Tabellen und anderen Plugins. Jede Entity verwendet die Konstante, niemals einen literalen String.

Entity-Base-Pattern

Jede Entity folgt der gleichen Struktur:

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

Regeln:

  • Jede Entity erweitert VendureEntity (liefert id, createdAt, updatedAt)
  • Jede Entity verwendet TABLE_NAMES.X für den Tabellennamen
  • Jede Entity enthält channelId für Multi-Channel-Isolation
  • Klassennamen verwenden das Ci-Prefix: CiReview, CiWishlist, CiLoyaltyAccount
  • Der Konstruktor akzeptiert DeepPartial<T> für saubere Instanziierung
  • Indizes werden auf der Entity definiert, nicht in Migrations, für bessere Sichtbarkeit

Soft Deletes

Für Entities, die Audit-Trails oder Wiederherstellung brauchen, nutze deletedAt statt harter Löschung. Filtere in allen 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-Trennung

Shop-API-Resolver (kundenorientiert) und Admin-API-Resolver (Backoffice) sind immer separate Klassen. Das ist kein Vorschlag. Es ist eine Designvorgabe, die verhindert, dass Admin-Operationen versehentlich Kunden zugänglich werden.

// 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); // enthält 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);
    }
}

Der Shop-Resolver gibt productReviews frei (nur genehmigte, öffentlicher Zugriff). Der Admin-Resolver gibt allReviews frei (alle Status, Admin-Zugriff) und moderateReview (nur Admin). Diese teilen sich niemals eine Klasse.

EventBus-Kommunikation

Module kommunizieren ausschließlich über den EventBus. Das Wunschlisten-Modul importiert nicht den Service des Treue-Moduls. Das Bewertungs-Modul importiert nicht den Service des Benachrichtigungs-Moduls.

// Modul A emittiert ein Event
@Injectable()
export class ReviewService {
    constructor(private eventBus: EventBus) {}

    async submit(ctx: RequestContext, input: SubmitReviewInput): Promise<CiReview> {
        const review = await this.createReview(ctx, input);

        // Event emittieren, nicht den Loyalty-Service direkt aufrufen
        await this.eventBus.publish(new CiReviewSubmittedEvent(ctx, review));

        return review;
    }
}

// Modul B abonniert und verarbeitet
@Injectable()
export class LoyaltyEventHandler {
    constructor(
        private eventBus: EventBus,
        private loyaltyService: LoyaltyService,
    ) {
        this.eventBus.ofType(CiReviewSubmittedEvent).subscribe(async event => {
            // Treuepunkte für das Einreichen einer Bewertung vergeben
            await this.loyaltyService.awardPoints(
                event.ctx,
                event.review.customerId,
                'review_submitted',
                50, // Punkte
            );
        });
    }
}

Damit bleiben Modulgrenzen sauber. Du kannst das Treue-Modul entfernen, ohne das Bewertungs-Modul zu beschädigen. Das Event wird veröffentlicht, egal ob jemand abonniert hat oder nicht.

Wie wir EventBus-Patterns in Nicht-Vendure-Systemen anwenden, zeigt unser Leitfaden für Event-Driven Architecture.

Multi-Pod-Koordination

Leader Election für Scheduler

Geplante Jobs (verlassene Warenkörbe erkennen, Treuepunkte verfallen lassen, Preisänderungen prüfen) müssen auf genau einem Pod laufen. Ohne Leader Election läuft der Scheduler auf jedem Pod, und du bekommst doppelte Verarbeitung.

Vendures JobQueue löst das. Nur ein Worker nimmt einen Job aus der Queue. Plane Jobs über die Queue, nicht über 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);
            },
        });
    }

    // Wird von einem Scheduler oder Cron-Trigger aufgerufen
    async scheduleDetection(ctx: RequestContext) {
        await this.detectionQueue.add({}, { ctx });
    }
}

Benachrichtigungs-Deduplizierung

Wenn alle Pods das gleiche Event konsumieren, könnte jeder Pod versuchen, die gleiche Benachrichtigung zu senden. Der Deduplizierungs-Store verhindert Duplikate:

@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; // Heute bereits gesendet

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

Der Dedupe-Key enthält einen Tages-Bucket. Die gleiche Benachrichtigung kann morgen erneut gesendet werden, aber nie zweimal am selben Tag. Der Unique-Constraint auf dedupeKey verhindert Race Conditions zwischen Pods.

GraphQL-Schema-Patterns

Erweitere Vendures Shop- und Admin-APIs mit 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
`;

// Automatisch generierte Sort-/Filter-Optionen über Vendures ListQueryBuilder

Vendures ListQueryBuilder generiert automatisch Sort- und Filter-Optionen für jede Entity, die PaginatedList implementiert. Du bekommst Filterung nach jeder Spalte und Sortierung gratis.

Dashboard-Entwicklung (React)

Vendure 3s React-basierte Admin-UI bietet Extension Points für 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-Seiten verwenden TanStack Query für Data Fetching und Vendures Dashboard-SDK-Komponenten:

// 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>
                {/* Bewertungsliste mit Moderations-Controls rendern */}
            </PageBlock>
        </Page>
    );
}

Für unseren Vendure-Produktionsarchitektur-Leitfaden, der die umfassendere Plattformbewertung einschließlich der React-Admin-UI-Fähigkeiten abdeckt.

Testing

Unit Tests

Teste Service-Methoden mit gemockten 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

Teste die vollständige GraphQL-API mit einem echten 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 () => {
        // Bewertung einreichen (pending)
        await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
        await shopClient.query(SUBMIT_REVIEW, {
            input: { productId: '1', rating: 4, body: 'Good' },
        });

        // Öffentliche Abfrage sollte keine ausstehenden Bewertungen zeigen
        await shopClient.asAnonymousUser();
        const { productReviews } = await shopClient.query(PRODUCT_REVIEWS, { productId: '1' });
        expect(productReviews.items.every(r => r.status === 'approved')).toBe(true);
    });
});

Vendures createTestEnvironment startet einen echten Server mit SQLite, führt Migrations aus, befüllt Testdaten und stellt authentifizierte Clients für beide APIs bereit. Tests laufen gegen die tatsächliche GraphQL-Oberfläche.

Für weiterführende Testing-Patterns siehe unseren Software-Engineering-Leitfaden.

Häufige Fehler

  1. Hardcodierte Tabellennamen. Verwende Konstanten aus shared/constants. Mit 30+ Entities garantieren hardcodierte Strings Tippfehler und Inkonsistenzen.

  2. Gemischte Shop/Admin-Resolver. Separate Klassen. Immer. Eine einzelne Resolver-Klasse mit sowohl öffentlichen als auch Admin-Queries ist ein Sicherheitsvorfall, der darauf wartet, zu passieren.

  3. Cross-Module-Service-Imports. Nutze den EventBus. Wenn Modul A den Service von Modul B importiert, hast du eine Kopplung geschaffen, die bricht, sobald sich eines der Module ändert.

  4. Kein Channel-Scoping. Jede Query muss nach ctx.channelId filtern. Fehlt das, werden Daten zwischen Storefronts geleakt.

  5. setInterval für Scheduling. Nutze Vendures JobQueue. setInterval läuft auf jedem Pod, hat keine Fehlerbehandlung und keine Observability.

  6. Keine Benachrichtigungs-Deduplizierung. Ohne Dedupe-Store sendet jeder Pod, der ein Event empfängt, die gleiche E-Mail. Kunden bekommen 3 Kopien jeder Benachrichtigung.

  7. Nur mit SQLite testen. TypeORM verhält sich auf SQLite und PostgreSQL unterschiedlich. Spaltentypen, JSON-Handling und Transaction Isolation unterscheiden sich alle. Teste mit PostgreSQL, bevor du auslieferst.

  8. Keine Idempotenz bei Mutations. Netzwerk-Retries erzeugen doppelte Wunschlisten, doppelte Bewertungen, doppelte Treue-Transaktionen. Baue Idempotenz von Tag eins ein.

Zentrale Erkenntnisse

  • Entity-Konventionen verhindern Chaos bei Skalierung. Table-Name-Konstanten, Ci-Prefix, VendureEntity-Basisklasse, Channel-Scoping und Soft Deletes. Diese Patterns sind langweilig, aber unverzichtbar bei 30+ Entities.

  • Resolver-Trennung ist eine Sicherheitsgrenze. Shop-Resolver geben öffentliche/Owner-Daten frei. Admin-Resolver geben Management-Operationen frei. Mische sie niemals.

  • EventBus ist die einzige Inter-Modul-Kommunikation. Keine Cross-Module-Service-Imports. Events halten Module unabhängig und entfernbar.

  • Multi-Pod-Koordination passiert nicht automatisch. Leader Election für Scheduler, Deduplizierung für Benachrichtigungen, Idempotenz für Mutations. Plane von Tag eins für mehrere Pods.

  • Teste mit einem echten Vendure-Server. createTestEnvironment gibt dir einen echten Server mit echtem GraphQL. Teste Berechtigungen, Datenisolation und Business-Regeln gegen die tatsächliche API-Oberfläche.

Wir bauen Enterprise-Vendure-Plugins als Teil unserer E-Commerce-Praxis. Wenn du ein komplexes Vendure-Plugin baust oder Hilfe mit Plugin-Architektur brauchst, sprich mit unserem Team oder fordere ein Angebot an. Siehe auch unseren Vendure Headless Commerce Leitfaden für mehr über unsere Vendure-Arbeit.

Behandelte Themen

Vendure Plugin-EntwicklungVendure Custom PluginVendure NestJSVendure EntityVendure EventBusVendure Multi-PodVendure TestingVendure Enterprise Plugin

Bereit, produktionsreife KI-Systeme zu bauen?

Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.

Gespräch starten