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