Developing a Custom Vendure Plugin
Plugins in Vendure are NestJS modules with additional decorators. The extension mechanism is fully predictable: a plugin can add GraphQL resolvers, database entities, services, event handlers, new fields to existing types. No monkey-patching, only DI and TypeORM.
Plugin Structure
src/plugins/loyalty/
├── loyalty.plugin.ts # Entry point (NestJS Module)
├── loyalty.service.ts # Business logic
├── loyalty.resolver.ts # GraphQL resolvers
├── loyalty.entity.ts # TypeORM entity
├── loyalty-ui/ # Admin UI extension (optional)
│ ├── loyalty.module.ts
│ └── components/
└── types.ts # GraphQL types
@VendurePlugin Decorator
// loyalty.plugin.ts
import { PluginCommonModule, Type, VendurePlugin } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";
import { LoyaltyResolver } from "./loyalty.resolver";
import { LoyaltyAccount } from "./loyalty.entity";
import { loyaltyShopApiExtensions, loyaltyAdminApiExtensions } from "./api-extensions";
@VendurePlugin({
imports: [PluginCommonModule],
entities: [LoyaltyAccount],
shopApiExtensions: {
schema: loyaltyShopApiExtensions,
resolvers: [LoyaltyResolver],
},
adminApiExtensions: {
schema: loyaltyAdminApiExtensions,
resolvers: [LoyaltyAdminResolver],
},
providers: [LoyaltyService],
configuration: (config) => {
// Can modify global config
config.orderOptions.orderItemPriceCalculationStrategy =
new LoyaltyAwarePriceStrategy();
return config;
},
})
export class LoyaltyPlugin {}
TypeORM Entity
// loyalty.entity.ts
import {
DeepPartial,
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
import { Customer, VendureEntity } from "@vendure/core";
@Entity()
export class LoyaltyAccount extends VendureEntity {
constructor(input?: DeepPartial<LoyaltyAccount>) {
super(input);
}
@ManyToOne(() => Customer, { onDelete: "CASCADE" })
customer: Customer;
@Column()
customerId: string;
@Column({ default: 0 })
points: number;
@Column({ type: "jsonb", nullable: true })
transactions: LoyaltyTransaction[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
interface LoyaltyTransaction {
type: "earn" | "spend";
points: number;
orderId?: string;
reason: string;
date: string;
}
After adding entity to entities: [LoyaltyAccount], Vendure creates table via migration.
GraphQL Schema Extension
// api-extensions.ts
import gql from "graphql-tag";
export const loyaltyShopApiExtensions = gql`
type LoyaltyAccount {
id: ID!
points: Int!
transactions: [LoyaltyTransaction!]!
}
type LoyaltyTransaction {
type: String!
points: Int!
reason: String!
date: DateTime!
orderId: ID
}
extend type Query {
myLoyaltyAccount: LoyaltyAccount
}
extend type Mutation {
redeemLoyaltyPoints(points: Int!): Order!
}
`;
// Extending existing Customer type in Admin API
export const loyaltyAdminApiExtensions = gql`
extend type Customer {
loyaltyAccount: LoyaltyAccount
}
`;
Service with EventBus
// loyalty.service.ts
import { Injectable } from "@nestjs/common";
import { EventBus, OrderPlacedEvent, RequestContext, TransactionalConnection } from "@vendure/core";
import { OnEvent } from "@nestjs/event-emitter";
import { LoyaltyAccount } from "./loyalty.entity";
@Injectable()
export class LoyaltyService implements OnApplicationBootstrap {
constructor(
private connection: TransactionalConnection,
private eventBus: EventBus,
) {}
onApplicationBootstrap() {
// Subscribe to order completion event
this.eventBus.ofType(OrderPlacedEvent).subscribe(async (event) => {
await this.awardPointsForOrder(event.ctx, event.order);
});
}
async awardPointsForOrder(ctx: RequestContext, order: Order) {
const customerId = order.customerId;
if (!customerId) return; // anonymous order
const pointsToAward = Math.floor(order.totalWithTax / 100); // 1 point = 100 kopecks
await this.connection.withTransaction(ctx, async (em) => {
let account = await em.findOne(LoyaltyAccount, {
where: { customerId },
});
if (!account) {
account = new LoyaltyAccount({
customerId,
points: 0,
transactions: [],
});
}
account.points += pointsToAward;
account.transactions = [
...account.transactions,
{
type: "earn",
points: pointsToAward,
orderId: order.id,
reason: `Order #${order.code}`,
date: new Date().toISOString(),
},
];
await em.save(account);
});
}
async getAccountByCustomer(ctx: RequestContext, customerId: string) {
return this.connection
.getRepository(ctx, LoyaltyAccount)
.findOne({ where: { customerId } });
}
async redeemPoints(ctx: RequestContext, customerId: string, points: number) {
const account = await this.getAccountByCustomer(ctx, customerId);
if (!account || account.points < points) {
throw new UserInputError("Insufficient points");
}
account.points -= points;
account.transactions.push({
type: "spend",
points,
reason: "Redemption on order",
date: new Date().toISOString(),
});
return this.connection.getRepository(ctx, LoyaltyAccount).save(account);
}
}
GraphQL Resolver
// loyalty.resolver.ts
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from "@nestjs/graphql";
import { Ctx, RequestContext, Allow, Permission, ActiveOrderService } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";
@Resolver()
export class LoyaltyResolver {
constructor(
private loyaltyService: LoyaltyService,
private activeOrderService: ActiveOrderService,
) {}
@Query()
@Allow(Permission.Owner)
async myLoyaltyAccount(@Ctx() ctx: RequestContext) {
if (!ctx.activeUserId) return null;
return this.loyaltyService.getAccountByCustomer(
ctx,
ctx.activeUserId.toString()
);
}
@Mutation()
@Allow(Permission.Owner)
async redeemLoyaltyPoints(
@Ctx() ctx: RequestContext,
@Args("points") points: number,
) {
const order = await this.activeOrderService.getActiveOrder(ctx, undefined);
if (!order) throw new Error("No active order");
await this.loyaltyService.redeemPoints(ctx, ctx.activeUserId!.toString(), points);
// add discount to order...
return order;
}
}
Registering Plugin in Config
// vendure-config.ts
import { LoyaltyPlugin } from "./plugins/loyalty/loyalty.plugin";
export const config: VendureConfig = {
// ...
plugins: [
// ...
LoyaltyPlugin,
],
};
Migration After Adding Plugin
npm run build
npx ts-node src/migration.ts generate src/migrations/AddLoyaltyPlugin
npx ts-node src/migration.ts run
Testing Plugin
// loyalty.service.spec.ts
import { Test } from "@nestjs/testing";
import { createTestEnvironment, testConfig } from "@vendure/testing";
import { LoyaltyPlugin } from "./loyalty.plugin";
describe("LoyaltyPlugin", () => {
const { server, adminClient, shopClient } = createTestEnvironment({
...testConfig,
plugins: [LoyaltyPlugin],
});
beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, "test-products.csv"),
});
});
it("awards points after order placement", async () => {
await shopClient.asUserWithCredentials("[email protected]", "test");
await shopClient.query(ADD_ITEM_TO_ORDER, { variantId: "T_1", quantity: 1 });
// ... complete checkout
const { myLoyaltyAccount } = await shopClient.query(GET_LOYALTY_ACCOUNT);
expect(myLoyaltyAccount.points).toBeGreaterThan(0);
});
});
Vendure provides createTestEnvironment — full test instance with in-memory SQLite, no mocks.







