Building Production-Ready GraphQL APIs with AWS AppSync
8 min read
REST APIs were great. Until your mobile app needed 14 endpoints to render a single screen. Until frontend developers started complaining about overfetching. Until you realized you're basically rebuilding GraphQL, poorly.
AWS AppSync is a fully managed GraphQL service that eliminates the operational complexity of running GraphQL servers wh0ile providing real-time subscriptions, offline sync, and enterprise security.
Let's build a production-ready GraphQL API that would make your frontend team smile.
Why AppSync vs DIY GraphQL?
DIY GraphQL (Apollo Server + Lambda):
You manage the GraphQL server
You implement resolvers
You handle WebSocket connections for subscriptions
You build caching logic
You configure authorization
You monitor and scale everything
AWS AppSync:
Fully managed GraphQL layer
Built-in DynamoDB, Lambda, HTTP, RDS integration
Native WebSocket subscriptions
Automatic caching
Cognito, API Key, IAM, OIDC auth
Auto-scaling to 1M+ concurrent connections
Architecture Overview

Real-World Example: E-Commerce Product Catalog
Step 1: Define GraphQL Schema
# schema.graphql
# Product type
type Product {
id: ID!
name: String!
description: String
price: Float!
category: Category!
inventory: Int!
images: [String!]
ratings: RatingsSummary
reviews(limit: Int = 10, nextToken: String): ReviewConnection
createdAt: AWSDateTime!
updatedAt: AWSDateTime!
}
# Category type
type Category {
id: ID!
name: String!
slug: String!
products(limit: Int = 20): ProductConnection
}
# Ratings summary
type RatingsSummary {
average: Float!
count: Int!
distribution: RatingsDistribution!
}
type RatingsDistribution {
fiveStars: Int!
fourStars: Int!
threeStars: Int!
twoStars: Int!
oneStar: Int!
}
# Review with pagination
type Review {
id: ID!
productId: ID!
customerId: ID!
customerName: String!
rating: Int!
title: String!
content: String!
verified: Boolean!
helpful: Int!
createdAt: AWSDateTime!
}
type ReviewConnection {
items: [Review!]!
nextToken: String
}
type ProductConnection {
items: [Product!]!
nextToken: String
}
# Inputs
input CreateProductInput {
name: String!
description: String
price: Float!
categoryId: ID!
inventory: Int!
images: [String!]
}
input UpdateProductInput {
id: ID!
name: String
description: String
price: Float
inventory: Int
images: [String!]
}
input CreateReviewInput {
productId: ID!
rating: Int!
title: String!
content: String!
}
# Queries
type Query {
# Get single product
getProduct(id: ID!): Product
# List products with filtering
listProducts(
categoryId: ID
minPrice: Float
maxPrice: Float
limit: Int = 20
nextToken: String
): ProductConnection
# Search products
searchProducts(
query: String!
limit: Int = 20
): ProductConnection
# Get category
getCategory(id: ID!): Category
# List categories
listCategories: [Category!]!
}
# Mutations
type Mutation {
# Product mutations
createProduct(input: CreateProductInput!): Product
updateProduct(input: UpdateProductInput!): Product
deleteProduct(id: ID!): ID
# Review mutations
createReview(input: CreateReviewInput!): Review
markReviewHelpful(reviewId: ID!): Review
}
# Subscriptions
type Subscription {
# Subscribe to product updates
onProductUpdated(id: ID!): Product
@aws_subscribe(mutations: ["updateProduct"])
# Subscribe to new reviews
onReviewCreated(productId: ID!): Review
@aws_subscribe(mutations: ["createReview"])
# Subscribe to inventory changes
onInventoryChanged(productId: ID!): Product
@aws_subscribe(mutations: ["updateProduct"])
}
# Authorization directives
directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION
directive @aws_api_key on FIELD_DEFINITION
Step 2: Create AppSync API with CDK
// lib/appsync-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as appsync from 'aws-cdk-lib/aws-appsync';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as cognito from 'aws-cdk-lib/aws-cognito';
export class AppSyncStack extends cdk.Stack {
constructor(scope: cdk.App, id: string) {
super(scope, id);
// DynamoDB tables
const productsTable = new dynamodb.Table(this, 'ProductsTable', {
tableName: 'products',
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
const reviewsTable = new dynamodb.Table(this, 'ReviewsTable', {
tableName: 'reviews',
partitionKey: { name: 'productId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
// Cognito User Pool for authentication
const userPool = new cognito.UserPool(this, 'UserPool', {
userPoolName: 'ecommerce-users',
selfSignUpEnabled: true,
signInAliases: { email: true },
autoVerify: { email: true },
});
// AppSync API
const api = new appsync.GraphqlApi(this, 'ProductAPI', {
name: 'product-catalog-api',
schema: appsync.SchemaFile.fromAsset('schema/schema.graphql'),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.USER_POOL,
userPoolConfig: {
userPool,
},
},
additionalAuthorizationModes: [
{
authorizationType: appsync.AuthorizationType.API_KEY,
apiKeyConfig: {
expires: cdk.Expiration.after(cdk.Duration.days(365)),
},
},
],
},
xrayEnabled: true, // Enable X-Ray tracing
logConfig: {
fieldLogLevel: appsync.FieldLogLevel.ALL,
excludeVerboseContent: false,
},
});
// DynamoDB data sources
const productsDataSource = api.addDynamoDbDataSource(
'ProductsDataSource',
productsTable
);
const reviewsDataSource = api.addDynamoDbDataSource(
'ReviewsDataSource',
reviewsTable
);
// Lambda data source for complex logic
const searchFunction = new lambda.Function(this, 'SearchFunction', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'search.handler',
code: lambda.Code.fromAsset('lambda/search'),
environment: {
OPENSEARCH_ENDPOINT: 'search-endpoint.us-east-1.es.amazonaws.com',
},
});
const searchDataSource = api.addLambdaDataSource(
'SearchDataSource',
searchFunction
);
// Resolvers
this.createResolvers(api, productsDataSource, reviewsDataSource, searchDataSource);
// Outputs
new cdk.CfnOutput(this, 'GraphQLAPIURL', {
value: api.graphqlUrl,
});
new cdk.CfnOutput(this, 'GraphQLAPIKey', {
value: api.apiKey || '',
});
}
private createResolvers(
api: appsync.GraphqlApi,
productsDS: appsync.DynamoDbDataSource,
reviewsDS: appsync.DynamoDbDataSource,
searchDS: appsync.LambdaDataSource
) {
// Query: getProduct
productsDS.createResolver('getProductResolver', {
typeName: 'Query',
fieldName: 'getProduct',
requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
// Query: listProducts
productsDS.createResolver('listProductsResolver', {
typeName: 'Query',
fieldName: 'listProducts',
requestMappingTemplate: appsync.MappingTemplate.fromString(`
{
"version": "2017-02-28",
"operation": "Scan",
"limit": $util.defaultIfNull($ctx.args.limit, 20),
"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.nextToken, null))
}
`),
responseMappingTemplate: appsync.MappingTemplate.fromString(`
{
"items": $util.toJson($ctx.result.items),
"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.result.nextToken, null))
}
`),
});
// Query: searchProducts (Lambda)
searchDS.createResolver('searchProductsResolver', {
typeName: 'Query',
fieldName: 'searchProducts',
});
// Mutation: createProduct
productsDS.createResolver('createProductResolver', {
typeName: 'Mutation',
fieldName: 'createProduct',
requestMappingTemplate: appsync.MappingTemplate.fromString(`
#set($id = $util.autoId())
#set($timestamp = $util.time.nowISO8601())
{
"version": "2017-02-28",
"operation": "PutItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($id)
},
"attributeValues": {
"name": $util.dynamodb.toDynamoDBJson($ctx.args.input.name),
"description": $util.dynamodb.toDynamoDBJson($ctx.args.input.description),
"price": $util.dynamodb.toDynamoDBJson($ctx.args.input.price),
"categoryId": $util.dynamodb.toDynamoDBJson($ctx.args.input.categoryId),
"inventory": $util.dynamodb.toDynamoDBJson($ctx.args.input.inventory),
"images": $util.dynamodb.toDynamoDBJson($ctx.args.input.images),
"createdAt": $util.dynamodb.toDynamoDBJson($timestamp),
"updatedAt": $util.dynamodb.toDynamoDBJson($timestamp)
}
}
`),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
// Field resolver: Product.reviews
reviewsDS.createResolver('productReviewsResolver', {
typeName: 'Product',
fieldName: 'reviews',
requestMappingTemplate: appsync.MappingTemplate.fromString(`
{
"version": "2017-02-28",
"operation": "Query",
"query": {
"expression": "productId = :productId",
"expressionValues": {
":productId": $util.dynamodb.toDynamoDBJson($ctx.source.id)
}
},
"limit": $util.defaultIfNull($ctx.args.limit, 10),
"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.nextToken, null)),
"scanIndexForward": false
}
`),
responseMappingTemplate: appsync.MappingTemplate.fromString(`
{
"items": $util.toJson($ctx.result.items),
"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.result.nextToken, null))
}
`),
});
}
}
Step 3: JavaScript Resolvers (Faster than VTL)
AppSync now supports JavaScript resolvers (faster, more testable):
// Using APPSYNC_JS runtime
const jsResolver = new appsync.Resolver(this, 'JSResolver', {
api,
typeName: 'Query',
fieldName: 'getProduct',
dataSource: productsDS,
runtime: appsync.FunctionRuntime.JS_1_0_0,
code: appsync.Code.fromAsset('resolvers/getProduct.js'),
});
// resolvers/getProduct.js
import { util } from '@aws-appsync/utils';
export function request(ctx) {
return {
operation: 'GetItem',
key: util.dynamodb.toMapValues({ id: ctx.args.id }),
};
}
export function response(ctx) {
if (ctx.error) {
util.error(ctx.error.message, ctx.error.type);
}
return ctx.result;
}
Step 4: Pipeline Resolvers for Complex Logic
Pipeline resolvers chain multiple data sources:
// Create functions
const getProductFunction = new appsync.AppsyncFunction(this, 'GetProductFunc', {
name: 'getProduct',
api,
dataSource: productsDS,
code: appsync.Code.fromInline(`
export function request(ctx) {
return {
operation: 'GetItem',
key: util.dynamodb.toMapValues({ id: ctx.args.id })
};
}
export function response(ctx) {
return ctx.result;
}
`),
runtime: appsync.FunctionRuntime.JS_1_0_0,
});
const getRatingsFunction = new appsync.AppsyncFunction(this, 'GetRatingsFunc', {
name: 'getRatings',
api,
dataSource: reviewsDS,
code: appsync.Code.fromInline(`
export function request(ctx) {
return {
operation: 'Query',
query: {
expression: 'productId = :id',
expressionValues: util.dynamodb.toMapValues({ ':id': ctx.prev.result.id })
}
};
}
export function response(ctx) {
const reviews = ctx.result.items;
const ratings = reviews.map(r => r.rating);
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
return {
average: avg,
count: ratings.length,
distribution: calculateDistribution(ratings)
};
}
function calculateDistribution(ratings) {
return {
fiveStars: ratings.filter(r => r === 5).length,
fourStars: ratings.filter(r => r === 4).length,
threeStars: ratings.filter(r => r === 3).length,
twoStars: ratings.filter(r => r === 2).length,
oneStar: ratings.filter(r => r === 1).length,
};
}
`),
runtime: appsync.FunctionRuntime.JS_1_0_0,
});
// Pipeline resolver
new appsync.Resolver(this, 'GetProductWithRatings', {
api,
typeName: 'Query',
fieldName: 'getProductWithRatings',
pipelineConfig: [getProductFunction, getRatingsFunction],
runtime: appsync.FunctionRuntime.JS_1_0_0,
code: appsync.Code.fromInline(`
export function request(ctx) {
return {};
}
export function response(ctx) {
return {
...ctx.prev.result[0], // Product
ratings: ctx.prev.result[1] // Ratings
};
}
`),
});
Step 5: Real-Time Subscriptions
Subscriptions work automatically with @aws_subscribe directive:
type Subscription {
onProductUpdated(id: ID!): Product
@aws_subscribe(mutations: ["updateProduct"])
}
Frontend usage:
// React with AWS Amplify
import { API, graphqlOperation } from 'aws-amplify';
const subscription = API.graphql(
graphqlOperation(`
subscription OnProductUpdated($id: ID!) {
onProductUpdated(id: $id) {
id
name
price
inventory
}
}
`, { id: 'product-123' })
).subscribe({
next: ({ value }) => {
console.log('Product updated:', value.data.onProductUpdated);
updateUI(value.data.onProductUpdated);
},
error: (error) => console.error('Subscription error:', error),
});
// Cleanup
subscription.unsubscribe();
Advanced Features
Caching
const api = new appsync.GraphqlApi(this, 'CachedAPI', {
name: 'cached-api',
schema,
authorizationConfig,
caching: {
behavior: appsync.CachingBehavior.FULL_REQUEST_CACHING,
ttl: cdk.Duration.minutes(5),
},
});
Field-Level Authorization
type Product {
id: ID!
name: String!
price: Float!
# Only admins can see cost
cost: Float! @aws_auth(cognito_groups: ["Admins"])
# Only authenticated users can see reviews
reviews: [Review!]! @aws_auth(cognito_groups: ["Users", "Admins"])
}
Batch Resolvers (Solve N+1 Problem)
// Batch multiple DynamoDB queries
export function request(ctx) {
const ids = ctx.source.items.map(item => item.categoryId);
return {
operation: 'BatchGetItem',
tables: {
categories: ids.map(id => util.dynamodb.toMapValues({ id }))
}
};
}
Client-Side Usage
React Query + AppSync
import { useQuery, useMutation } from '@tanstack/react-query';
import { API, graphqlOperation } from 'aws-amplify';
const GET_PRODUCTS = `
query ListProducts($limit: Int) {
listProducts(limit: $limit) {
items {
id
name
price
images
}
}
}
`;
function ProductList() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: async () => {
const result = await API.graphql(graphqlOperation(GET_PRODUCTS, { limit: 20 }));
return result.data.listProducts.items;
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
const CREATE_REVIEW = `
mutation CreateReview($input: CreateReviewInput!) {
createReview(input: $input) {
id
rating
title
content
}
}
`;
function ReviewForm({ productId }) {
const mutation = useMutation({
mutationFn: async (reviewInput) => {
const result = await API.graphql(
graphqlOperation(CREATE_REVIEW, { input: reviewInput })
);
return result.data.createReview;
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({
productId,
rating: 5,
title: 'Great product!',
content: 'Highly recommend.',
});
};
return <form onSubmit={handleSubmit}>{/* Form fields */}</form>;
}
Best Practices
Use pipeline resolvers for multi-source queries
Enable caching for frequently accessed data
Implement field-level auth for sensitive data
Use batch resolvers to avoid N+1 queries
Enable X-Ray tracing for debugging
Set appropriate TTLs for subscriptions
Use JavaScript resolvers over VTL for complex logic
Monitor API usage with CloudWatch metrics
Version your schema carefully (additive changes only)
Test with AppSync console before deploying
Conclusion
AWS AppSync transforms GraphQL from an operational burden into a strategic advantage. Real-time subscriptions, automatic scaling, and native AWS integrations - all without managing servers.
Start with a simple schema, add resolvers, enable subscriptions. Your frontend team will finally stop complaining about APIs.
Building with AppSync? Share your schema design patterns!