Modern E-commerce Backend with GraphQL, Apollo Server & PostgreSQL
A comprehensive E-commerce GraphQL API demonstrating modern backend architecture with advanced GraphQL patterns. This project showcases a complete online shopping platform backend with products, shopping cart, orders, and user management.
Built with Apollo Server and PostgreSQL, this API leverages GraphQL's powerful features including nested queries, real-time subscriptions, efficient data loading with DataLoader, and optimistic caching strategies.
The architecture demonstrates production-ready practices including authentication with JWT, input validation, error handling, database transactions, and comprehensive testing coverage.
type Product {
id: ID!
name: String!
description: String!
price: Float!
stock: Int!
category: Category!
images: [String!]!
rating: Float
reviews: [Review!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Category {
id: ID!
name: String!
slug: String!
products: [Product!]!
}
type Cart {
id: ID!
user: User!
items: [CartItem!]!
totalPrice: Float!
updatedAt: DateTime!
}
type CartItem {
id: ID!
product: Product!
quantity: Int!
subtotal: Float!
}
type Order {
id: ID!
user: User!
items: [OrderItem!]!
totalPrice: Float!
status: OrderStatus!
shippingAddress: Address!
paymentMethod: PaymentMethod!
createdAt: DateTime!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type User {
id: ID!
email: String!
name: String!
orders: [Order!]!
cart: Cart
createdAt: DateTime!
}
type Query {
# Product queries
products(
limit: Int
offset: Int
sortBy: ProductSortBy
category: ID
minPrice: Float
maxPrice: Float
search: String
): ProductConnection!
product(id: ID!): Product
# Category queries
categories: [Category!]!
category(slug: String!): Category
# User queries
me: User
# Cart queries
myCart: Cart
# Order queries
myOrders(limit: Int, offset: Int): [Order!]!
order(id: ID!): Order
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
type Mutation {
# Authentication
register(input: RegisterInput!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
# Cart mutations
addToCart(productId: ID!, quantity: Int!): Cart!
updateCartItem(itemId: ID!, quantity: Int!): Cart!
removeFromCart(itemId: ID!): Cart!
clearCart: Cart!
# Order mutations
createOrder(input: CreateOrderInput!): Order!
cancelOrder(orderId: ID!): Order!
# Product mutations (Admin only)
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
}
input CreateOrderInput {
shippingAddress: AddressInput!
paymentMethod: PaymentMethod!
}
input AddressInput {
street: String!
city: String!
state: String!
zipCode: String!
country: String!
}
type Subscription {
# Product stock updates
productStockUpdated(productId: ID!): Product!
# Order status updates
orderStatusChanged(orderId: ID!): Order!
# Cart updates (multi-device sync)
cartUpdated(userId: ID!): Cart!
}
# GraphQL Query
query SearchProducts {
products(
limit: 20
offset: 0
sortBy: PRICE_LOW_TO_HIGH
category: "electronics"
minPrice: 100
maxPrice: 1000
search: "laptop"
) {
edges {
node {
id
name
price
stock
rating
images
category {
name
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
totalCount
}
}
// resolvers/product.js
const productResolvers = {
Query: {
products: async (_, args, { dataSources, loaders }) => {
const { limit = 20, offset = 0, sortBy, category, minPrice, maxPrice, search } = args;
const products = await dataSources.productAPI.findAll({
limit,
offset,
sortBy,
filters: { category, minPrice, maxPrice, search }
});
const totalCount = await dataSources.productAPI.count({
category, minPrice, maxPrice, search
});
return {
edges: products.map(product => ({
node: product,
cursor: Buffer.from(product.id).toString('base64')
})),
pageInfo: {
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0
},
totalCount
};
},
product: async (_, { id }, { loaders }) => {
return loaders.productLoader.load(id);
}
},
Product: {
category: async (product, _, { loaders }) => {
return loaders.categoryLoader.load(product.categoryId);
},
reviews: async (product, _, { loaders }) => {
return loaders.reviewsByProductLoader.load(product.id);
}
}
};
// resolvers/cart.js
const cartResolvers = {
Mutation: {
addToCart: async (_, { productId, quantity }, { user, dataSources }) => {
if (!user) throw new AuthenticationError('Not authenticated');
// Check product availability
const product = await dataSources.productAPI.findById(productId);
if (!product) throw new UserInputError('Product not found');
if (product.stock < quantity) {
throw new UserInputError('Insufficient stock');
}
// Add to cart
const cart = await dataSources.cartAPI.addItem({
userId: user.id,
productId,
quantity
});
// Publish cart update for real-time sync
pubsub.publish('CART_UPDATED', {
cartUpdated: cart,
userId: user.id
});
return cart;
},
updateCartItem: async (_, { itemId, quantity }, { user, dataSources }) => {
if (!user) throw new AuthenticationError('Not authenticated');
if (quantity <= 0) {
return dataSources.cartAPI.removeItem(itemId, user.id);
}
const cart = await dataSources.cartAPI.updateItem({
itemId,
quantity,
userId: user.id
});
pubsub.publish('CART_UPDATED', {
cartUpdated: cart,
userId: user.id
});
return cart;
}
}
};
// resolvers/order.js
const orderResolvers = {
Mutation: {
createOrder: async (_, { input }, { user, dataSources, db }) => {
if (!user) throw new AuthenticationError('Not authenticated');
// Start database transaction
const transaction = await db.beginTransaction();
try {
// Get user's cart
const cart = await dataSources.cartAPI.getByUserId(user.id);
if (!cart || cart.items.length === 0) {
throw new UserInputError('Cart is empty');
}
// Verify stock availability for all items
for (const item of cart.items) {
const product = await dataSources.productAPI.findById(item.productId);
if (product.stock < item.quantity) {
throw new UserInputError(
`Insufficient stock for ${product.name}`
);
}
}
// Create order
const order = await dataSources.orderAPI.create({
userId: user.id,
items: cart.items,
totalPrice: cart.totalPrice,
shippingAddress: input.shippingAddress,
paymentMethod: input.paymentMethod
}, transaction);
// Update product stock
for (const item of cart.items) {
await dataSources.productAPI.decreaseStock(
item.productId,
item.quantity,
transaction
);
}
// Clear cart
await dataSources.cartAPI.clear(user.id, transaction);
// Commit transaction
await transaction.commit();
// Send order confirmation email
await emailService.sendOrderConfirmation(user.email, order);
// Publish order created event
pubsub.publish('ORDER_STATUS_CHANGED', {
orderStatusChanged: order
});
return order;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
};
// resolvers/subscription.js
const subscriptionResolvers = {
Subscription: {
productStockUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['PRODUCT_STOCK_UPDATED']),
(payload, variables) => {
return payload.productStockUpdated.id === variables.productId;
}
)
},
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterator(['ORDER_STATUS_CHANGED']),
(payload, variables, context) => {
return (
payload.orderStatusChanged.id === variables.orderId &&
payload.orderStatusChanged.userId === context.user.id
);
}
)
},
cartUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['CART_UPDATED']),
(payload, variables, context) => {
return payload.userId === context.user.id;
}
),
resolve: (payload) => payload.cartUpdated
}
}
};
// dataloaders/index.js
const DataLoader = require('dataloader');
const createLoaders = ({ productAPI, categoryAPI, reviewAPI }) => {
// Product loader - batches product queries
const productLoader = new DataLoader(async (ids) => {
const products = await productAPI.findByIds(ids);
const productMap = new Map(products.map(p => [p.id, p]));
return ids.map(id => productMap.get(id));
});
// Category loader - batches category queries
const categoryLoader = new DataLoader(async (ids) => {
const categories = await categoryAPI.findByIds(ids);
const categoryMap = new Map(categories.map(c => [c.id, c]));
return ids.map(id => categoryMap.get(id));
});
// Reviews by product loader - batches review queries
const reviewsByProductLoader = new DataLoader(async (productIds) => {
const reviews = await reviewAPI.findByProductIds(productIds);
const reviewMap = new Map();
productIds.forEach(id => reviewMap.set(id, []));
reviews.forEach(review => {
reviewMap.get(review.productId).push(review);
});
return productIds.map(id => reviewMap.get(id));
});
return {
productLoader,
categoryLoader,
reviewsByProductLoader
};
};
module.exports = createLoaders;
| Feature | GraphQL | REST |
|---|---|---|
| Data Fetching | Single request for nested data | Multiple requests (N+1 problem) |
| Over-fetching | Request only needed fields | Receives entire resource |
| Versioning | No versioning needed | Requires API versioning (v1, v2) |
| Real-time | Built-in subscriptions | Requires WebSocket setup |
| Documentation | Self-documenting (introspection) | Requires separate docs |
| Type Safety | Strong typing with schema | No built-in type safety |
| Caching | Requires custom implementation | Built-in HTTP caching |
# REST - Multiple requests needed
GET /api/products/1
GET /api/products/1/category
GET /api/products/1/reviews
GET /api/users/5 # for each review
Total: 4+ requests, over-fetching data
# GraphQL - Single optimized request
query GetProduct {
product(id: "1") {
name
price
category {
name
}
reviews {
rating
comment
user {
name
}
}
}
}
Total: 1 request, exact data needed
// middleware/auth.js
const jwt = require('jsonwebtoken');
const getUser = (token) => {
try {
if (token) {
return jwt.verify(token, process.env.JWT_SECRET);
}
return null;
} catch (error) {
return null;
}
};
const context = ({ req }) => {
const token = req.headers.authorization || '';
const user = getUser(token.replace('Bearer ', ''));
return {
user,
dataSources: {
productAPI: new ProductAPI(),
cartAPI: new CartAPI(),
orderAPI: new OrderAPI()
}
};
};
// directives/auth.js
const { SchemaDirectiveVisitor } = require('graphql-tools');
const { AuthenticationError } = require('apollo-server');
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field;
const requiredRole = this.args.requires;
field.resolve = async function(...args) {
const [, , context] = args;
if (!context.user) {
throw new AuthenticationError('Not authenticated');
}
if (requiredRole && context.user.role !== requiredRole) {
throw new ForbiddenError('Not authorized');
}
return resolve.apply(this, args);
};
}
}
// Usage in schema
type Mutation {
createProduct(input: CreateProductInput!): Product!
@auth(requires: ADMIN)
deleteProduct(id: ID!): Boolean!
@auth(requires: ADMIN)
}
// validation/product.js
const { UserInputError } = require('apollo-server');
const Joi = require('joi');
const productSchema = Joi.object({
name: Joi.string().min(3).max(100).required(),
description: Joi.string().min(10).max(1000).required(),
price: Joi.number().positive().required(),
stock: Joi.number().integer().min(0).required(),
categoryId: Joi.string().uuid().required()
});
const validateProductInput = (input) => {
const { error, value } = productSchema.validate(input);
if (error) {
throw new UserInputError('Invalid input', {
validationErrors: error.details
});
}
return value;
};
// middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const limiter = rateLimit({
store: new RedisStore({
client: redisClient
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later'
});
app.use('/graphql', limiter);
// tests/integration/product.test.js
const { createTestClient } = require('apollo-server-testing');
const { gql } = require('apollo-server');
describe('Product Queries', () => {
let client, server;
beforeAll(async () => {
server = await createTestServer();
client = createTestClient(server);
});
afterAll(async () => {
await server.stop();
});
it('should fetch products with filters', async () => {
const SEARCH_PRODUCTS = gql`
query SearchProducts($search: String) {
products(search: $search, limit: 10) {
edges {
node {
id
name
price
}
}
totalCount
}
}
`;
const { data, errors } = await client.query({
query: SEARCH_PRODUCTS,
variables: { search: 'laptop' }
});
expect(errors).toBeUndefined();
expect(data.products.edges).toHaveLength(10);
expect(data.products.totalCount).toBeGreaterThan(0);
});
it('should handle pagination correctly', async () => {
const { data: page1 } = await client.query({
query: GET_PRODUCTS,
variables: { limit: 5, offset: 0 }
});
const { data: page2 } = await client.query({
query: GET_PRODUCTS,
variables: { limit: 5, offset: 5 }
});
expect(page1.products.pageInfo.hasNextPage).toBe(true);
expect(page1.products.edges[0].node.id)
.not.toBe(page2.products.edges[0].node.id);
});
});
// tests/unit/resolvers/cart.test.js
const { addToCart } = require('../../../resolvers/cart');
describe('Cart Resolver', () => {
it('should add product to cart', async () => {
const mockProduct = { id: '1', stock: 10 };
const mockUser = { id: 'user1' };
const mockCart = { id: 'cart1', items: [] };
const dataSources = {
productAPI: {
findById: jest.fn().mockResolvedValue(mockProduct)
},
cartAPI: {
addItem: jest.fn().mockResolvedValue(mockCart)
}
};
const result = await addToCart(
null,
{ productId: '1', quantity: 2 },
{ user: mockUser, dataSources }
);
expect(result).toEqual(mockCart);
expect(dataSources.cartAPI.addItem).toHaveBeenCalledWith({
userId: 'user1',
productId: '1',
quantity: 2
});
});
it('should throw error when stock insufficient', async () => {
const mockProduct = { id: '1', stock: 1 };
const dataSources = {
productAPI: {
findById: jest.fn().mockResolvedValue(mockProduct)
}
};
await expect(
addToCart(
null,
{ productId: '1', quantity: 5 },
{ user: { id: 'user1' }, dataSources }
)
).rejects.toThrow('Insufficient stock');
});
});
// cache/redis.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const cacheMiddleware = {
// Cache product queries
async getProduct(id) {
const cacheKey = `product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const product = await productAPI.findById(id);
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
},
// Invalidate cache on update
async updateProduct(id, data) {
await redis.del(`product:${id}`);
return productAPI.update(id, data);
}
};
// plugins/queryComplexity.js
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');
const queryComplexityPlugin = {
requestDidStart: () => ({
didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [
simpleEstimator({ defaultComplexity: 1 })
]
});
if (complexity > 1000) {
throw new Error(
`Query too complex: ${complexity}. Maximum allowed: 1000`
);
}
console.log('Query complexity:', complexity);
}
})
};
// datasources/product.js
class ProductAPI {
// Use database indexes
async findAll(filters) {
const query = Product.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category')
.select([
'product.id',
'product.name',
'product.price',
'product.stock',
'category.name'
]);
// Add filters with indexes
if (filters.search) {
query.andWhere(
'to_tsvector(product.name || \' \' || product.description) @@ plainto_tsquery(:search)',
{ search: filters.search }
);
}
if (filters.category) {
query.andWhere('product.categoryId = :categoryId', {
categoryId: filters.category
});
}
if (filters.minPrice || filters.maxPrice) {
query.andWhere('product.price BETWEEN :min AND :max', {
min: filters.minPrice || 0,
max: filters.maxPrice || Number.MAX_SAFE_INTEGER
});
}
return query.getMany();
}
}
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 4000
CMD ["node", "src/index.js"]
# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "4000:4000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/ecommerce
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: ecommerce
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
# .env.example NODE_ENV=production PORT=4000 # Database DATABASE_URL=postgresql://user:password@localhost:5432/ecommerce DATABASE_POOL_SIZE=20 # Redis REDIS_URL=redis://localhost:6379 REDIS_TTL=3600 # Authentication JWT_SECRET=your-secret-key JWT_EXPIRATION=7d # Email SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your-email@gmail.com SMTP_PASS=your-app-password # AWS S3 (for images) AWS_ACCESS_KEY_ID=your-key AWS_SECRET_ACCESS_KEY=your-secret AWS_S3_BUCKET=ecommerce-images # Payment Gateway STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...
This E-commerce GraphQL API demonstrates professional-level backend development with modern technologies and best practices. The project showcases expertise in:
The architecture is scalable, maintainable, and follows industry best practices for building enterprise-level GraphQL APIs suitable for production environments.