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

  1. Use pipeline resolvers for multi-source queries

  2. Enable caching for frequently accessed data

  3. Implement field-level auth for sensitive data

  4. Use batch resolvers to avoid N+1 queries

  5. Enable X-Ray tracing for debugging

  6. Set appropriate TTLs for subscriptions

  7. Use JavaScript resolvers over VTL for complex logic

  8. Monitor API usage with CloudWatch metrics

  9. Version your schema carefully (additive changes only)

  10. 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!