Skip to main content
Back to blog
Guides·Published onSep 19, 2025

Feature Recipe: Blameable Changes Plugin

HAS
Housein Abo ShaarGrowth Engineer & Developer Advocate, Vendure
A guide to building a Vendure plugin that automatically tracks which user creates or updates entities, using the EventBus system and custom fields for auditing.

This guide provides a recipe for a "blameable changes" plugin that automatically tracks which Administrator creates and updates product variants.

The plugin demonstrates several core Vendure concepts, including the EventBus for reacting to data changes, Custom Fields to extend core entities, and extending the GraphQL API with custom resolvers.

1. Plugin Architecture & Custom Fields

The first step is to define the custom fields that will store the user information.

This is done within the plugin's main configuration.


We will use the Vendure CLI to scaffold the plugin and service.

npx vendure add -p BlameableFields# then add the servicenpx vendure add -s BlameableService --selected-plugin BlameableFields

We add createdBy and updatedBy fields of type relation to the ProductVariant entity, linking them to the User entity.

File: src/plugins/blameable-fields/blameable-fields.plugin.ts

Typescript
// ... inside the @VendurePlugin decorator  configuration: config => {    // Add custom fields to the ProductVariant entity    config.customFields.ProductVariant.push(      {        name: 'createdBy',        type: 'relation',        entity: User, // Link to the User entity        nullable: true,        readonly: true // Prevent editing via the Admin UI      },      // ... similar field for 'updatedBy'    );    return config;  },// ...

2. Service for Event Handling

Next, we create a service that listens for product variant events and populates our new custom fields.

The service subscribes to the ProductVariantEvent on the EventBus. This is done inside the onModuleInit method, a lifecycle hook from NestJS (the framework Vendure is built on).

This hook ensures our subscription is set up once the plugin has been initialized.

Typescript
// ... inside the BlameableService class  onModuleInit() {    // Subscribe to events affecting ProductVariants    this.eventBus.ofType(ProductVariantEvent)    .subscribe(async (event) => {      // Pass the event to our handler method      await this.handleProductVariantEvent(event);    });  }

When an event is received, it checks the event type (created or updated). It then updates the corresponding custom field with the ID of the active user.

Typescript
// ... inside the BlameableService class  private async handleProductVariantEvent(event: ProductVariantEvent) {    const { ctx, entity, type } = event;        // Skip if there's no active user (e.g., a system process)    if (!ctx.activeUserId) {      return;    }    // Ensure we can handle both single and multiple entities in an event    const entities = Array.isArray(entity) ? entity : [entity];        for (const variant of entities) {      // Determine which field to update based on the event type      const fieldToUpdate = type === 'created' ? 'createdBy' : 'updatedBy';            // Update the custom field on the specific ProductVariant      await this.connection.getRepository(ctx, ProductVariant)      .update(variant.id, {        customFields: {          [fieldToUpdate]: ctx.activeUserId,        },      });    }  }

3. Exposing Data via GraphQL

To make the tracking data easily accessible, we extend the GraphQL schema.

We add createdByIdentifier and updatedByIdentifier fields to the ProductVariant type.

File: src/plugins/blameable-fields/blameable-fields.plugin.ts

Typescript
// ... inside the @VendurePlugin decorator  adminApiExtensions: {    // Extend the GraphQL schema    schema: gql`      extend type ProductVariant {        createdByIdentifier: String        updatedByIdentifier: String      }    `,    // Register the resolver to provide the data for the new fields    resolvers: [ProductVariantBlameableResolver],  },// ...

A resolver fetches the stored user ID and resolves it to a user identifier, like "superadmin". This approach uses a direct database query for better performance.

File: src/plugins/blameable-fields/resolvers/product-variant.resolver.ts

Typescript
@Resolver('ProductVariant')export class ProductVariantBlameableResolver {  constructor(private blameableService: BlameableService) {}  @ResolveField()  async createdByIdentifier(    @Ctx() ctx: RequestContext,    @Parent() variant: ProductVariant  ): Promise<string | null> {    // Get the user ID from the custom field    const createdByUserId = await this.blameableService    .getCreatedByUserId(ctx, variant.id);    if (!createdByUserId) return null;    // Resolve the ID to a user identifier string    return this.blameableService.resolveUserIdentifier(ctx, createdByUserId);  }  // ... similar resolver for updatedByIdentifier}

You could modify the service to track changes on other entities, such as Product, Customer, or Order by subscribing to their respective events. For more detailed auditing, the logic could be extended to store a full history of changes.

Further Reading

Share this article