Overview
EverShop uses GraphQL for data fetching and mutations. The GraphQL schema is built by merging type definitions and resolvers from all modules, providing a flexible and type-safe API.
GraphQL Module Structure
Each module can contribute GraphQL types and resolvers:
modules/catalog/graphql/
└── types/
├── Product/
│ ├── Product.graphql # Frontend type definition
│ ├── Product.resolvers.js # Frontend resolvers
│ ├── Product.admin.graphql # Admin-only types
│ └── Product.admin.resolvers.js # Admin-only resolvers
└── Category/
├── Category.graphql
├── Category.resolvers.js
├── Category.admin.graphql
└── Category.admin.resolvers.js
Files with .admin.graphql and .admin.resolvers.js are only included in the admin GraphQL schema, not the frontend schema.
Type Definitions
Basic Type Definition
modules/catalog/graphql/types/Product/Product.graphql
"""
Represents a product.
"""
type Product {
productId : Int !
uuid : String !
name : String !
status : Int !
sku : String !
weight : Weight !
noShippingRequired : Boolean
taxClass : Int
description : JSON
urlKey : String
metaTitle : String
metaDescription : String
metaKeywords : String
variantGroupId : ID
visibility : Int
groupId : ID
url : String
}
"""
Returns a collection of products.
"""
type ProductCollection {
items : [ Product ]
currentPage : Int !
total : Int !
currentFilters : [ Filter ]
}
"""
Returns a search.
"""
type ProductSearch {
products : ProductCollection
keyword : String
}
extend type Query {
product ( id : ID ): Product
currentProduct : Product
products ( filters : [ FilterInput ]): ProductCollection
productSearch : ProductSearch
}
Use extend type Query and extend type Mutation to add queries and mutations to the schema. Don’t redefine the Query or Mutation types.
Nested Types and Resolvers
Product has nested fields that are resolved separately:
Product/
├── Product.graphql # Base type
├── Price/
│ ├── Price.graphql # Price type definition
│ └── ProductPrice.resolvers.js # Price field resolver
├── Inventory/
│ ├── Inventory.graphql
│ └── Inventory.resolvers.js
├── Attribute/
│ ├── ProductAttribute.graphql
│ └── ProductAttribute.resolvers.js
└── Variant/
├── Variant.graphql
└── Variant.resolvers.js
Resolvers
Field Resolvers
Resolvers map GraphQL fields to data sources:
modules/catalog/graphql/types/Product/Price/ProductPrice.resolvers.js
export default {
Product: {
price : ( product ) => {
const price = parseFloat ( product . price );
const specialPrice = product . special_price
? parseFloat ( product . special_price )
: null ;
return {
regular: price ,
special: specialPrice
};
}
}
} ;
Query Resolvers
Query resolvers fetch data:
export default {
Query: {
product : async ( _ , { id }, { pool }) => {
const product = await select ()
. from ( 'product' )
. where ( 'uuid' , '=' , id )
. load ( pool );
return product ;
},
products : async ( _ , { filters }, { pool }) => {
const query = select (). from ( 'product' );
// Apply filters
if ( filters ) {
filters . forEach ( filter => {
query . andWhere ( filter . key , filter . operation , filter . value );
});
}
const products = await query . execute ( pool );
return {
items: products ,
total: products . length ,
currentPage: 1
};
}
}
} ;
Mutation Resolvers
Mutation resolvers modify data:
export default {
Mutation: {
createProduct : async ( _ , { product }, { pool }) => {
const result = await insert ( 'product' )
. given ( product )
. execute ( pool );
return {
success: true ,
product: result
};
},
updateProduct : async ( _ , { id , product }, { pool }) => {
await update ( 'product' )
. given ( product )
. where ( 'uuid' , '=' , id )
. execute ( pool );
return {
success: true
};
}
}
} ;
Schema Building
The GraphQL schema is built during application startup by merging all type definitions and resolvers.
Building Type Definitions
packages/evershop/src/modules/graphql/services/buildTypes.js
import path from 'path' ;
import { loadFilesSync } from '@graphql-tools/load-files' ;
import { mergeTypeDefs } from '@graphql-tools/merge' ;
import { getEnabledExtensions } from '../../../bin/extension/index.js' ;
import { CONSTANTS } from '../../../lib/helpers.js' ;
export function buildTypeDefs ( isAdmin = false ) {
const typeSources = [
path . join ( CONSTANTS . MODULESPATH , '*/graphql/types/**/*.graphql' )
];
// Add extension type sources
const extensions = getEnabledExtensions ();
extensions . forEach (( extension ) => {
typeSources . push (
path . join ( extension . path , 'graphql/types/**/*.graphql' )
);
});
// Merge all type definitions
const typeDefs = mergeTypeDefs (
typeSources . map (( source ) =>
loadFilesSync ( source , {
ignoredExtensions: isAdmin ? [] : [ '.admin.graphql' ]
})
)
);
return typeDefs ;
}
Building Resolvers
packages/evershop/src/modules/graphql/services/buildResolvers.js
import path from 'path' ;
import url from 'url' ;
import { loadFiles } from '@graphql-tools/load-files' ;
import { mergeResolvers } from '@graphql-tools/merge' ;
import { getEnabledExtensions } from '../../../bin/extension/index.js' ;
import { CONSTANTS } from '../../../lib/helpers.js' ;
export async function buildResolvers ( isAdmin = false ) {
const typeSources = [
path . join ( CONSTANTS . MODULESPATH , '*/graphql/types/**/*.resolvers.{js,ts}' )
];
// Add extension resolver sources
const extensions = getEnabledExtensions ();
extensions . forEach (( extension ) => {
typeSources . push (
path . join ( extension . path , 'graphql/types/**/*.resolvers.{js,ts}' )
);
});
// Merge all resolvers
const resolvers = mergeResolvers (
await loadFiles ( typeSources , {
ignoredExtensions: isAdmin
? [ '.ts' , '.d.ts' ]
: [ '.admin.resolvers.js' , '.admin.resolvers.ts' , '.ts' , '.d.ts' ],
requireMethod : async ( path ) => {
const module = await import ( url . pathToFileURL ( path ));
return module ;
}
})
);
return resolvers ;
}
Creating the Schema
packages/evershop/src/modules/graphql/services/buildSchema.js
import { makeExecutableSchema } from '@graphql-tools/schema' ;
import { buildResolvers } from './buildResolvers.js' ;
import { buildTypeDefs } from './buildTypes.js' ;
const resolvers = await buildResolvers ( true );
const schema = makeExecutableSchema ({
typeDefs: buildTypeDefs ( true ),
resolvers
});
export async function rebuildSchema () {
const resolvers = await buildResolvers ( true );
const schema = makeExecutableSchema ({
typeDefs: buildTypeDefs ( true ),
resolvers: resolvers
});
return schema ;
}
export default schema ;
GraphQL Endpoints
EverShop exposes two GraphQL endpoints:
Frontend GraphQL
modules/graphql/api/graphql/
├── route.json
└── graphql.js
{
"methods" : [ "POST" ],
"path" : "/api/graphql" ,
"access" : "public"
}
Admin GraphQL
modules/graphql/api/adminGraphql/
├── route.json
└── adminGraphql.js
{
"methods" : [ "POST" ],
"path" : "/api/admin/graphql"
}
The admin endpoint includes admin-only types and resolvers. The frontend endpoint excludes them for security.
Context Object
Resolvers receive a context object with useful utilities:
export default {
Query: {
products : async ( parent , args , context ) => {
const { pool , user , locale } = context ;
// pool: Database connection
// user: Authenticated user (if any)
// locale: Current locale for translations
// Your resolver logic
}
}
} ;
Extending Existing Types
You can extend types defined in other modules:
extensions/reviews/graphql/types/Product/ProductReviews.graphql
extend type Product {
reviews : [ Review ]
averageRating : Float
}
type Review {
reviewId : Int !
rating : Int !
comment : String
customerName : String
createdAt : String
}
extensions/reviews/graphql/types/Product/ProductReviews.resolvers.js
export default {
Product: {
reviews : async ( product , _ , { pool }) => {
return await select ()
. from ( 'product_review' )
. where ( 'product_id' , '=' , product . productId )
. execute ( pool );
},
averageRating : async ( product , _ , { pool }) => {
const result = await select ( 'AVG(rating) as avg' )
. from ( 'product_review' )
. where ( 'product_id' , '=' , product . productId )
. load ( pool );
return parseFloat ( result . avg );
}
}
} ;
Custom Scalars
Define custom scalar types for special data:
scalar JSON
scalar Date
scalar DateTime
import { GraphQLScalarType } from 'graphql' ;
export default {
JSON: new GraphQLScalarType ({
name: 'JSON' ,
description: 'JSON scalar type' ,
serialize : ( value ) => value ,
parseValue : ( value ) => value ,
parseLiteral : ( ast ) => JSON . parse ( ast . value )
})
} ;
Define input types for mutations:
input ProductInput {
name : String !
sku : String !
price : Float !
description : String
status : Int
}
input FilterInput {
key : String !
operation : String !
value : String !
}
extend type Mutation {
createProduct ( product : ProductInput ! ): ProductResponse
updateProduct ( id : ID ! , product : ProductInput ! ): ProductResponse
}
type ProductResponse {
success : Boolean !
product : Product
message : String
}
Error Handling
Handle errors gracefully in resolvers:
export default {
Mutation: {
createProduct : async ( _ , { product }, { pool }) => {
try {
const result = await insert ( 'product' )
. given ( product )
. execute ( pool );
return {
success: true ,
product: result
};
} catch ( error ) {
return {
success: false ,
message: error . message
};
}
}
}
} ;
Always handle errors in resolvers. Unhandled errors will expose internal details to clients.
DataLoader for N+1 Prevention
Use DataLoader to batch database queries:
import DataLoader from 'dataloader' ;
const categoryLoader = new DataLoader ( async ( categoryIds ) => {
const categories = await select ()
. from ( 'category' )
. where ( 'category_id' , 'IN' , categoryIds )
. execute ( pool );
return categoryIds . map ( id =>
categories . find ( cat => cat . category_id === id )
);
});
export default {
Product: {
category : ( product ) => {
return categoryLoader . load ( product . category_id );
}
}
} ;
Best Practices
Group related types together. For example, all Product-related types should be in Product/ subdirectories.
Use field resolvers for computed fields
Instead of loading all data upfront, use field resolvers to load data only when requested.
Extend types from other modules rather than modifying them. This keeps modules decoupled.
Use GraphQL comments (triple quotes) to document types and fields. These appear in GraphQL introspection.
Put business logic in service files, not in resolvers. Resolvers should orchestrate, not implement logic.
Example: Complete Feature
Here’s a complete example of adding reviews to products:
Type Definition
extensions/reviews/graphql/types/Review/Review.graphql
type Review {
reviewId : Int !
productId : Int !
rating : Int !
title : String
comment : String
customerName : String
status : Int !
createdAt : String
}
input ReviewInput {
productId : Int !
rating : Int !
title : String
comment : String
customerName : String !
}
type ReviewResponse {
success : Boolean !
review : Review
message : String
}
extend type Product {
reviews : [ Review ]
averageRating : Float
reviewCount : Int
}
extend type Query {
review ( id : ID ! ): Review
reviews ( productId : Int ! ): [ Review ]
}
extend type Mutation {
createReview ( review : ReviewInput ! ): ReviewResponse
approveReview ( id : ID ! ): ReviewResponse
}
Resolvers
extensions/reviews/graphql/types/Review/Review.resolvers.js
import { select , insert , update } from '@evershop/postgres-query-builder' ;
export default {
Query: {
review : async ( _ , { id }, { pool }) => {
return await select ()
. from ( 'product_review' )
. where ( 'review_id' , '=' , id )
. load ( pool );
},
reviews : async ( _ , { productId }, { pool }) => {
return await select ()
. from ( 'product_review' )
. where ( 'product_id' , '=' , productId )
. andWhere ( 'status' , '=' , 1 ) // Only approved
. execute ( pool );
}
} ,
Mutation: {
createReview : async ( _ , { review }, { pool }) => {
try {
const result = await insert ( 'product_review' )
. given ({ ... review , status: 0 }) // Pending approval
. execute ( pool );
return {
success: true ,
review: result
};
} catch ( error ) {
return {
success: false ,
message: error . message
};
}
},
approveReview : async ( _ , { id }, { pool }) => {
await update ( 'product_review' )
. given ({ status: 1 })
. where ( 'review_id' , '=' , id )
. execute ( pool );
const review = await select ()
. from ( 'product_review' )
. where ( 'review_id' , '=' , id )
. load ( pool );
return {
success: true ,
review
};
}
} ,
Product: {
reviews : async ( product , _ , { pool }) => {
return await select ()
. from ( 'product_review' )
. where ( 'product_id' , '=' , product . productId )
. andWhere ( 'status' , '=' , 1 )
. execute ( pool );
},
averageRating : async ( product , _ , { pool }) => {
const result = await select ( 'AVG(rating) as avg' )
. from ( 'product_review' )
. where ( 'product_id' , '=' , product . productId )
. andWhere ( 'status' , '=' , 1 )
. load ( pool );
return result ? parseFloat ( result . avg ) : 0 ;
},
reviewCount : async ( product , _ , { pool }) => {
const result = await select ( 'COUNT(*) as count' )
. from ( 'product_review' )
. where ( 'product_id' , '=' , product . productId )
. andWhere ( 'status' , '=' , 1 )
. load ( pool );
return parseInt ( result . count );
}
}
} ;
Next Steps
Middleware Learn about middleware execution
Events Integrate with the event system