AWS AppSync: Building Production GraphQL APIs Without Managing Infrastructure

REST APIs work fine until you have a client that needs data from six different endpoints in one screen load. Each endpoint is a round trip. The mobile client on a slow connection feels every one of them. GraphQL solves this by letting the client describe exactly what it needs in a single request. The server responds with precisely that data and nothing more.

AWS AppSync is a managed GraphQL service that connects resolvers directly to DynamoDB, Aurora Serverless, Lambda, HTTP endpoints, and ElasticSearch. You define your schema and wire up resolvers. AppSync handles request routing, authorization, subscriptions, and caching. You do not manage a GraphQL server. In this article I will walk through building a production AppSync API with multiple authorization modes, direct DynamoDB resolvers, and real-time subscriptions.

Setting Up AppSync with Terraform

resource "aws_appsync_graphql_api" "main" {
  name                = "production-api"
  authentication_type = "AMAZON_COGNITO_USER_POOLS"

  user_pool_config {
    user_pool_id   = aws_cognito_user_pool.main.id
    aws_region     = var.region
    default_action = "ALLOW"
  }

  additional_authentication_provider {
    authentication_type = "API_KEY"
  }

  additional_authentication_provider {
    authentication_type = "AWS_IAM"
  }

  log_config {
    cloudwatch_logs_role_arn = aws_iam_role.appsync_logging.arn
    field_log_level          = "ERROR"
    exclude_verbose_content  = true
  }

  xray_enabled = true

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_appsync_api_key" "main" {
  api_id  = aws_appsync_graphql_api.main.id
  expires = timeadd(timestamp(), "8760h")
}

resource "aws_appsync_domain_name" "main" {
  domain_name     = "api.yourdomain.com"
  certificate_arn = aws_acm_certificate.main.arn
}

resource "aws_appsync_domain_name_api_association" "main" {
  domain_name = aws_appsync_domain_name.main.domain_name
  api_id      = aws_appsync_graphql_api.main.id
}

Three authorization modes are configured here. Cognito User Pools is the default for authenticated users. API Key covers public or third-party access where you want rate limiting without requiring user authentication. AWS IAM covers server-to-server access from Lambda functions or ECS tasks using their execution roles. Requests include an x-amz-security-token header and AppSync validates the IAM signature without any additional setup.

Defining the Schema

resource "aws_appsync_graphql_api" "main" {
  schema = file("${path.module}/schema.graphql")
}
type Order {
  orderId:    ID!
  customerId: ID!
  status:     OrderStatus!
  totalAmount: Float!
  items:      [OrderItem!]!
  createdAt:  AWSDateTime!
}

type OrderItem {
  productId: ID!
  quantity:  Int!
  price:     Float!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type Query {
  getOrder(orderId: ID!): Order
    @aws_cognito_user_pools
  listOrdersByCustomer(customerId: ID!, limit: Int, nextToken: String): OrderConnection!
    @aws_cognito_user_pools
  listOrdersByStatus(status: OrderStatus!, limit: Int): OrderConnection!
    @aws_cognito_user_pools @aws_iam
}

type Mutation {
  createOrder(input: CreateOrderInput!): Order!
    @aws_cognito_user_pools
  updateOrderStatus(orderId: ID!, status: OrderStatus!): Order!
    @aws_cognito_user_pools @aws_iam
}

type Subscription {
  onOrderStatusUpdated(customerId: ID!): Order
    @aws_subscribe(mutations: ["updateOrderStatus"])
    @aws_cognito_user_pools
}

type OrderConnection {
  items:     [Order!]!
  nextToken: String
}

input CreateOrderInput {
  customerId:  ID!
  items:       [OrderItemInput!]!
  totalAmount: Float!
}

The @aws_cognito_user_pools and @aws_iam directives on individual fields control which authorization modes can access each operation. listOrdersByStatus is accessible by both Cognito users and IAM principals, which covers your backend fulfillment service reading orders by status. updateOrderStatus is also dual-auth, allowing both the user-facing app and the fulfillment Lambda to update order status. The subscription automatically fires whenever updateOrderStatus is called.

Direct DynamoDB Resolvers

resource "aws_appsync_datasource" "orders_table" {
  api_id           = aws_appsync_graphql_api.main.id
  name             = "OrdersTable"
  type             = "AMAZON_DYNAMODB"
  service_role_arn = aws_iam_role.appsync_dynamodb.arn

  dynamodb_config {
    table_name = aws_dynamodb_table.orders.name
    region     = var.region
  }
}

resource "aws_appsync_resolver" "get_order" {
  api_id      = aws_appsync_graphql_api.main.id
  type        = "Query"
  field       = "getOrder"
  data_source = aws_appsync_datasource.orders_table.name
  kind        = "UNIT"

  request_template = <<-EOT
    {
      "version": "2018-05-29",
      "operation": "GetItem",
      "key": {
        "PK": $util.dynamodb.toDynamoDBJson("ORDER#$ctx.args.orderId"),
        "SK": $util.dynamodb.toDynamoDBJson("ORDER#$ctx.args.orderId")
      }
    }
  EOT

  response_template = <<-EOT
    #if($ctx.error)
      $util.error($ctx.error.message, $ctx.error.type)
    #end
    #if(!$ctx.result)
      $util.error("Order not found", "NOT_FOUND")
    #end
    $util.toJson($ctx.result)
  EOT
}

resource "aws_appsync_resolver" "list_orders_by_customer" {
  api_id      = aws_appsync_graphql_api.main.id
  type        = "Query"
  field       = "listOrdersByCustomer"
  data_source = aws_appsync_datasource.orders_table.name
  kind        = "UNIT"

  request_template = <<-EOT
    {
      "version": "2018-05-29",
      "operation": "Query",
      "index": "GSI1",
      "query": {
        "expression": "GSI1PK = :pk",
        "expressionValues": {
          ":pk": $util.dynamodb.toDynamoDBJson("CUSTOMER#$ctx.args.customerId")
        }
      },
      "limit": $util.defaultIfNull($ctx.args.limit, 20),
      #if($ctx.args.nextToken) "nextToken": "$ctx.args.nextToken", #end
      "scanIndexForward": false
    }
  EOT

  response_template = <<-EOT
    {
      "items": $util.toJson($ctx.result.items),
      "nextToken": $util.toJson($ctx.result.nextToken)
    }
  EOT
}

Direct DynamoDB resolvers run without a Lambda function in the middle. AppSync translates the GraphQL request into a DynamoDB operation using Velocity Template Language and returns the result. For simple read and write operations this eliminates cold starts, reduces latency, and removes Lambda costs entirely. Use Lambda resolvers only when you need business logic that VTL cannot express.

Caching

resource "aws_appsync_api_cache" "main" {
  api_id                     = aws_appsync_graphql_api.main.id
  type                       = "SMALL"
  ttl                        = 300
  at_rest_encryption_enabled = true
  transit_encryption_enabled = true
  api_caching_behavior       = "PER_RESOLVER_CACHING"
}

PER_RESOLVER_CACHING lets you enable caching on individual resolvers with custom TTLs rather than caching all queries. Enable it on resolvers that serve frequently-read, infrequently-changed data like product catalogs and user profiles. Leave it disabled on resolvers that must always return fresh data like order status. Add the caching directive to your resolver configuration to activate it per field.

Closing Thoughts

AppSync removes a significant amount of boilerplate from GraphQL API development. Direct resolvers eliminate Lambda functions for data access patterns that map cleanly to DynamoDB operations. Subscriptions are built in rather than requiring a WebSocket server. Multiple authorization modes cover every client type from a mobile app to a backend service in a single API definition.

Design your schema around your client access patterns, not your database schema. Use direct resolvers for simple CRUD operations and Lambda resolvers for business logic. Enable per-resolver caching selectively. The result is a flexible, scalable API layer that your frontend teams can evolve independently of your backend services.

Enjoy the cloud.

Osama


#AWS #AppSync #GraphQL #CloudArchitecture #ServerlessArchitecture #APIDesign #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudNative #CloudComputing #BackendEngineering #DynamoDB #TechBlog #CloudInfrastructure #Cognito #RealTime #Subscriptions #DevOps

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.