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.
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(liefertid,createdAt,updatedAt) - Jede Entity verwendet
TABLE_NAMES.Xfür den Tabellennamen - Jede Entity enthält
channelIdfü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
-
Hardcodierte Tabellennamen. Verwende Konstanten aus
shared/constants. Mit 30+ Entities garantieren hardcodierte Strings Tippfehler und Inkonsistenzen. -
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.
-
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.
-
Kein Channel-Scoping. Jede Query muss nach
ctx.channelIdfiltern. Fehlt das, werden Daten zwischen Storefronts geleakt. -
setInterval für Scheduling. Nutze Vendures JobQueue.
setIntervalläuft auf jedem Pod, hat keine Fehlerbehandlung und keine Observability. -
Keine Benachrichtigungs-Deduplizierung. Ohne Dedupe-Store sendet jeder Pod, der ein Event empfängt, die gleiche E-Mail. Kunden bekommen 3 Kopien jeder Benachrichtigung.
-
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.
-
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.
createTestEnvironmentgibt 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
Verwandte Guides
Vendure in Produktion: Stärken, Lücken und wann du es wählen solltest
Eine ehrliche Architektur-Bewertung von Vendure für den Produktionseinsatz. Plugin-System, EventBus, Worker Service, AdminUI, Multi-Pod-Deployment und Vergleich mit Medusa und Saleor.
Guide lesenUnternehmenshandbuch zu Agentischen KI-Systemen
Technischer Leitfaden zu agentischen KI-Systemen in Unternehmen. Erfahre mehr ueber Architektur, Faehigkeiten und Anwendungen autonomer KI-Agenten.
Guide lesenAgentic Commerce: Wie du KI-Agenten sicher einkaufen lässt
Wie du gesteuerten, KI-initiierten Handel designst. Policy Engines, HITL-Freigabe-Gates, HMAC-Quittungen, Idempotenz, Tenant-Scoping und das vollständige Agentic Checkout Protocol.
Guide lesenBereit, 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