vendure-graphql-writing
Vendure GraphQL Writing
Purpose
Guide creation of GraphQL schema extensions and resolvers in Vendure following official patterns.
When NOT to Use
- Plugin structure only (use vendure-plugin-writing)
- Entity definition only (use vendure-entity-writing)
- Reviewing existing code (use vendure-graphql-reviewing)
FORBIDDEN Patterns
- Missing @Ctx() RequestContext parameter
- Not using @Resolver() decorator
- Bypassing @Allow() permission decorator
- Returning raw entities without proper types
- Mixing Shop and Admin schema types
- Using hardcoded strings in gql schema
- Missing error handling in resolvers
REQUIRED Patterns
- @Resolver() decorator on resolver classes
- @Ctx() ctx: RequestContext as first parameter
- @Allow() decorator specifying permissions
- gql template literal for schema definition
- Separate Admin and Shop schema files
- Proper input types for mutations
- Service injection via constructor
Workflow
Step 1: Define GraphQL Schema
// schema.ts
import { gql } from "graphql-tag";
// Admin API schema - full access
export const graphqlAdminSchema = gql`
type MyCustomType {
id: ID!
name: String!
createdAt: DateTime!
internalNotes: String # Admin-only field
}
input CreateMyTypeInput {
name: String!
}
input UpdateMyTypeInput {
name: String
}
extend type Query {
myCustomTypes: [MyCustomType!]!
myCustomType(id: ID!): MyCustomType
}
extend type Mutation {
createMyCustomType(input: CreateMyTypeInput!): MyCustomType!
updateMyCustomType(id: ID!, input: UpdateMyTypeInput!): MyCustomType!
deleteMyCustomType(id: ID!): Boolean!
}
`;
// Shop API schema - customer-facing
export const graphqlShopSchema = gql`
type MyCustomType {
id: ID!
name: String!
# internalNotes excluded for customers
}
extend type Query {
myCustomTypes: [MyCustomType!]! # Read-only
}
`;
Step 2: Create Admin Resolver
// admin.resolver.ts
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
import {
Allow,
Ctx,
Permission,
RequestContext,
Transaction,
} from "@vendure/core";
import { MyService } from "./my.service";
import { MyEntity } from "./my.entity";
@Resolver()
export class MyAdminResolver {
constructor(private myService: MyService) {}
@Query()
@Allow(Permission.ReadSettings)
async myCustomTypes(@Ctx() ctx: RequestContext): Promise<MyEntity[]> {
return this.myService.findAll(ctx);
}
@Query()
@Allow(Permission.ReadSettings)
async myCustomType(
@Ctx() ctx: RequestContext,
@Args() args: { id: string },
): Promise<MyEntity | null> {
return this.myService.findOne(ctx, args.id);
}
@Mutation()
@Transaction()
@Allow(Permission.UpdateSettings)
async createMyCustomType(
@Ctx() ctx: RequestContext,
@Args() args: { input: CreateMyTypeInput },
): Promise<MyEntity> {
return this.myService.create(ctx, args.input);
}
@Mutation()
@Transaction()
@Allow(Permission.UpdateSettings)
async updateMyCustomType(
@Ctx() ctx: RequestContext,
@Args() args: { id: string; input: UpdateMyTypeInput },
): Promise<MyEntity> {
return this.myService.update(ctx, args.id, args.input);
}
@Mutation()
@Transaction()
@Allow(Permission.DeleteSettings)
async deleteMyCustomType(
@Ctx() ctx: RequestContext,
@Args() args: { id: string },
): Promise<boolean> {
return this.myService.delete(ctx, args.id);
}
}
Step 3: Create Shop Resolver
// shop.resolver.ts
import { Args, Query, Resolver } from "@nestjs/graphql";
import { Allow, Ctx, Permission, RequestContext } from "@vendure/core";
import { MyService } from "./my.service";
@Resolver()
export class MyShopResolver {
constructor(private myService: MyService) {}
@Query()
@Allow(Permission.Public) // Available to all customers
async myCustomTypes(@Ctx() ctx: RequestContext): Promise<MyEntity[]> {
return this.myService.findAllPublic(ctx);
}
}
Step 4: Register in Plugin
// my-plugin.plugin.ts
import { PluginCommonModule, VendurePlugin } from "@vendure/core";
import { graphqlAdminSchema, graphqlShopSchema } from "./schema";
import { MyAdminResolver } from "./admin.resolver";
import { MyShopResolver } from "./shop.resolver";
import { MyService } from "./my.service";
@VendurePlugin({
imports: [PluginCommonModule],
providers: [MyService],
adminApiExtensions: {
schema: graphqlAdminSchema,
resolvers: [MyAdminResolver],
},
shopApiExtensions: {
schema: graphqlShopSchema,
resolvers: [MyShopResolver],
},
})
export class MyPlugin {}
Common Patterns
Field Resolver
@Resolver("MyCustomType")
export class MyFieldResolver {
constructor(private relatedService: RelatedService) {}
@ResolveField()
async relatedItems(
@Ctx() ctx: RequestContext,
@Parent() parent: MyEntity,
): Promise<RelatedEntity[]> {
return this.relatedService.findByParentId(ctx, parent.id);
}
}
InputMaybe Handling (Critical)
// GraphQL generates InputMaybe<T> for optional fields
// MUST check both undefined AND null
async update(ctx: RequestContext, id: ID, input: UpdateInput): Promise<MyEntity> {
const entity = await this.findOne(ctx, id);
// WRONG: Only checks undefined
if (input.name !== undefined) {
entity.name = input.name; // Bug: null passes through!
}
// CORRECT: Check both
if (input.name !== undefined && input.name !== null) {
entity.name = input.name;
}
return this.connection.getRepository(ctx, MyEntity).save(entity);
}
Permission Combinations
// Public access
@Allow(Permission.Public)
// Authenticated customer
@Allow(Permission.Authenticated)
// Admin with specific permission
@Allow(Permission.ReadCatalog)
@Allow(Permission.UpdateCatalog)
// Multiple permissions (any of these)
@Allow(Permission.ReadOrder, Permission.Owner)
// Owner permission for customer's own resources
@Allow(Permission.Owner)
async myOrders(@Ctx() ctx: RequestContext): Promise<Order[]> {
// ctx.activeUserId available for filtering
}
Error Handling
import { UserInputError, ForbiddenError } from '@vendure/core';
@Mutation()
@Transaction()
@Allow(Permission.UpdateSettings)
async updateMyType(
@Ctx() ctx: RequestContext,
@Args() args: { id: string; input: UpdateInput },
): Promise<MyEntity> {
const entity = await this.myService.findOne(ctx, args.id);
if (!entity) {
throw new UserInputError(`Entity with id ${args.id} not found`);
}
if (!this.canUpdate(ctx, entity)) {
throw new ForbiddenError();
}
return this.myService.update(ctx, args.id, args.input);
}
Pagination
// Schema
gql`
type MyTypeList implements PaginatedList {
items: [MyType!]!
totalItems: Int!
}
extend type Query {
myTypes(options: MyTypeListOptions): MyTypeList!
}
input MyTypeListOptions {
skip: Int
take: Int
sort: MyTypeSortParameter
filter: MyTypeFilterParameter
}
`;
// Resolver
@Query()
@Allow(Permission.ReadSettings)
async myTypes(
@Ctx() ctx: RequestContext,
@Args() args: { options?: ListQueryOptions<MyEntity> },
): Promise<PaginatedList<MyEntity>> {
return this.myService.findAll(ctx, args.options);
}
Examples
Example 1: Extending Product Type
// Add custom field resolver to existing Product type
const schema = gql`
extend type Product {
customScore: Int!
}
`;
@Resolver("Product")
export class ProductScoreResolver {
constructor(private scoreService: ScoreService) {}
@ResolveField()
async customScore(
@Ctx() ctx: RequestContext,
@Parent() product: Product,
): Promise<number> {
return this.scoreService.calculateScore(ctx, product.id);
}
}
Example 2: Shop API with Customer Verification
// Verify customer owns the resource
@Resolver()
export class CustomerOrderResolver {
constructor(
private orderService: OrderService,
private activeOrderService: ActiveOrderService,
) {}
@Mutation()
@Allow(Permission.Owner)
async updateDeliveryDate(
@Ctx() ctx: RequestContext,
@Args() args: { orderId: string; date: string },
): Promise<Order> {
// Verify ownership
const activeOrder = await this.activeOrderService.getActiveOrder(ctx, {});
if (!activeOrder || activeOrder.id !== args.orderId) {
throw new ForbiddenError("Cannot modify this order");
}
return this.orderService.updateDeliveryDate(ctx, args.orderId, args.date);
}
}
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Resolver not called | Not in resolvers array | Add to adminApiExtensions.resolvers |
| Permission denied | Missing @Allow | Add @Allow(Permission.X) decorator |
| Type error | Schema/TypeScript mismatch | Regenerate types with codegen |
| ctx undefined | Missing @Ctx() decorator | Add @Ctx() ctx: RequestContext |
| Mutation not saving | Missing @Transaction() | Add @Transaction() decorator |
Related Skills
- vendure-graphql-reviewing - Review GraphQL code
- vendure-plugin-writing - Plugin structure
- vendure-entity-writing - Entity definitions
More from meriley/claude-code-skills
obs-cpp-qt-patterns
C++ and Qt integration patterns for OBS Studio plugins. Covers Qt6 Widgets for settings dialogs, CMAKE_AUTOMOC, OBS frontend API, optional Qt builds with C fallbacks, and modal dialog patterns. Use when adding UI components or C++ features to OBS plugins.
55vendure-developing
Develop Vendure e-commerce plugins, extend GraphQL APIs, create Admin UI components, and define database entities. Use vendure-expert agent for comprehensive guidance across all Vendure development domains.
36vendure-admin-ui-writing
Create Vendure Admin UI extensions with React components, route registration, navigation menus, and GraphQL integration. Handles useQuery, useMutation, useInjector patterns. Use when building Admin UI features for Vendure plugins.
33vendure-entity-writing
Define Vendure database entities extending VendureEntity, with TypeORM decorators, relations, custom fields, and channel-awareness. Use when creating database models in Vendure.
31vendure-plugin-writing
Create production-ready Vendure plugins with @VendurePlugin decorator, NestJS dependency injection, lifecycle hooks, and configuration patterns. Use when developing new Vendure plugins or extending existing ones.
29vendure-admin-ui-reviewing
Review Vendure Admin UI extensions for React pattern violations, missing hooks, improper state management, and UI anti-patterns. Use when reviewing Admin UI PRs or auditing UI quality.
26