Siddhant Deval
Siddhant Deval
backend17 min read

AppSync Foundations: Schema, Unit Resolvers & Direct Data Sources

AppSync is not 'GraphQL with Lambda underneath' — it is a managed execution engine where resolvers connect directly to data sources. This article covers schema design with SDL, unit resolver architecture with the APPSYNC_JS runtime, connecting resolvers directly to DynamoDB and HTTP endpoints without Lambda, and the auth modes available on an AppSync API.

AppSync Foundations: Schema, Unit Resolvers & Direct Data Sources

Every AWS primitive is a tradeoff surface, not a feature toggle. A common AppSync architecture wires every GraphQL mutation and query to a Lambda resolver — because tutorials do, and because "Lambda handles everything" feels safe. The hidden cost: every GetUser query now pays a cold-start risk, Lambda invocation billing, and ~50ms minimum added latency for a resolver whose entire function body is dynamoDB.getItem() and return item. AppSync's managed resolver model exists precisely to eliminate this pattern. For the majority of CRUD operations, the correct data source is DynamoDB connected directly — no Lambda in the path.

Series boundary: AppSync is a managed GraphQL execution engine distinct from self-hosted GraphQL runtimes. This article covers AppSync exclusively. For self-hosted schema design, N+1 DataLoader patterns, and Apollo Federation, see the GraphQL Backend & API Design series.


1. What AppSync Is (and Is Not)

AppSync is not an API Gateway for GraphQL — it is a fully managed GraphQL execution engine with built-in resolver execution, real-time WebSocket subscription management, multiple auth mode support, and a caching layer. The closest analogy: AppSync is to GraphQL what DynamoDB is to NoSQL — a managed service that handles the infrastructure concerns so you write only business logic.

1.1 When to Choose AppSync over API Gateway + Lambda

Use case AppSync API Gateway + Lambda
Real-time subscriptions (WebSocket) ✅ Managed — no WebSocket server code ❌ Requires WebSocket API + DynamoDB connection tracking
Multi-source resolver per field ✅ Pipeline resolvers compose multiple sources ❌ Lambda must orchestrate manually
GraphQL schema validation ✅ Built-in SDL validation + execution ❌ Apollo/Pothos setup required
Managed auth modes (Cognito, API key, IAM) ✅ Configurable per operation ❌ Must implement in Lambda
REST API alongside GraphQL ❌ GraphQL only ✅ REST routes supported
Complex business logic per request Depends — Lambda data source for complex cases ✅ Full Node.js/Python/etc. runtime

2. Schema Definition Language (SDL)

The SDL is the contract between your API and all consumers. Every breaking change in the SDL is a breaking change for every client that depends on it.

2.1 Types and Fields

GRAPHQL
# AppSync SDL — foundational schema for an e-commerce API

type User {
  id: ID!           # Non-null — resolver guarantees this field is always present
  email: String!    # Non-null — client can depend on this being a string, never null
  name: String      # Nullable — may be absent (e.g., anonymous user with no display name)
  tier: UserTier!   # Enum — constrained to valid values
  orders: [Order!]  # List of non-null orders (list itself may be null — user may have no orders loaded)
  createdAt: AWSDateTime!  # AppSync custom scalar — ISO 8601 datetime, validated by AppSync
}

enum UserTier {
  FREE
  PRO
  ENTERPRISE
}

type Order {
  id: ID!
  userId: ID!
  status: OrderStatus!
  total: Float!
  items: [OrderItem!]!
  createdAt: AWSDateTime!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type OrderItem {
  productId: ID!
  quantity: Int!
  unitPrice: Float!
}
Crucial Requirement

Non-null declarations (!) are a client contract enforced by AppSync. If a resolver returns null for a non-null field, AppSync propagates null upward through the response tree until it reaches a nullable ancestor — which may null out the entire response object. Use non-null only for fields your resolver can unconditionally return. Marking a field non-null and then returning null from the resolver is a runtime error, not a compile error.

2.2 Operations

GRAPHQL
type Query {
  getUser(id: ID!): User           # Nullable return — user may not exist
  listOrders(userId: ID!, status: OrderStatus, limit: Int): OrderConnection!
}

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

type Subscription {
  onOrderStatusChanged(userId: ID!): Order
    @aws_subscribe(mutations: ["updateOrderStatus", "cancelOrder"])
}

input CreateOrderInput {
  userId: ID!
  items: [OrderItemInput!]!
}

input OrderItemInput {
  productId: ID!
  quantity: Int!
}

type OrderConnection {
  items: [Order!]!
  nextToken: String   # DynamoDB pagination cursor
}

2.3 AppSync Custom Scalars

AppSync provides built-in scalar types beyond the GraphQL specification defaults:

Scalar Format Use case
AWSDateTime ISO 8601 with timezone Timestamps
AWSDate YYYY-MM-DD Date-only fields
AWSTime HH:mm:ss Time-only fields
AWSTimestamp Unix epoch (integer) Machine timestamps
AWSJSON JSON string Untyped JSON blobs
AWSEmail RFC 5321 email Email fields with validation
AWSURL RFC 3986 URL URLs
AWSPhone E.164 phone Phone numbers
AWSIPAddress IPv4/IPv6 IP address fields

3. The APPSYNC_JS Runtime

APPSYNC_JS is a JavaScript execution environment running in a V8 sandbox. Resolvers written in APPSYNC_JS replace the legacy VTL mapping template syntax. Each resolver has two exported functions:

  • request(ctx) — receives the GraphQL context, returns the operation to perform on the data source
  • response(ctx) — receives the data source result, returns the transformed GraphQL response
TYPESCRIPT
// APPSYNC_JS resolver runtime constraints:
// ✅ Supported:
//   - ES2022 syntax (arrow functions, destructuring, optional chaining, nullish coalescing)
//   - @aws-appsync/utils package (DynamoDB helpers, HTTP helpers)
//   - Synchronous operations only
//
// ❌ NOT supported:
//   - fetch() or any network calls
//   - npm packages (only @aws-appsync/utils is available)
//   - async/await
//   - setTimeout / setInterval
//   - process, Buffer, fs, or any Node.js built-ins

import { util, runtime } from '@aws-appsync/utils'
// util: encoding, error throwing, type conversion
// runtime: early exit with runtime.earlyReturn()

4. Unit Resolvers — Connecting Fields to Data Sources

A unit resolver maps a single GraphQL field to a single data source. It has one request() function and one response() function. It connects to exactly one data source per invocation.

4.1 DynamoDB Unit Resolver — GetItem

TYPESCRIPT
// Resolver: Query.getUser → DynamoDB GetItem (no Lambda)
// File: resolvers/Query.getUser.js

import { util } from '@aws-appsync/utils'
import { get } from '@aws-appsync/utils/dynamodb'

export function request(ctx) {
  // ctx.args: the GraphQL operation arguments ({ id: "user_42" })
  // ctx.identity: the caller's auth context (Cognito claims, IAM principal, etc.)
  return get({
    key: {
      PK: `USER#${ctx.args.id}`,
      SK: 'PROFILE',
    }
  })
  // The get() helper from @aws-appsync/utils/dynamodb generates the DynamoDB GetItem payload
}

export function response(ctx) {
  // ctx.result: the raw DynamoDB GetItem response
  // ctx.error: set if DynamoDB returned an error
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type)
  }
  if (!ctx.result) {
    return null // User not found — returns null since getUser return type is nullable
  }
  // Map DynamoDB item → GraphQL User type
  return {
    id: ctx.result.PK.replace('USER#', ''),
    email: ctx.result.email,
    name: ctx.result.name ?? null,
    tier: ctx.result.tier,
    createdAt: ctx.result.createdAt,
  }
}

4.2 DynamoDB Unit Resolver — PutItem (Mutation)

TYPESCRIPT
// Resolver: Mutation.createOrder → DynamoDB PutItem
// File: resolvers/Mutation.createOrder.js

import { util, runtime } from '@aws-appsync/utils'
import { put } from '@aws-appsync/utils/dynamodb'

export function request(ctx) {
  const { input } = ctx.args
  const orderId = util.autoId() // Generates a UUID — available in @aws-appsync/utils

  // Validate input — util.error() throws and stops resolver execution
  if (!input.items || input.items.length === 0) {
    util.error('Order must contain at least one item', 'ValidationError')
  }

  const total = input.items.reduce(
    (sum, item) => sum + (item.quantity * item.unitPrice), 0
  )

  return put({
    key: {
      PK: `ORDER#${orderId}`,
      SK: 'METADATA',
    },
    item: {
      orderId,
      userId: input.userId,
      status: 'PENDING',
      total,
      items: input.items,
      createdAt: util.time.nowISO8601(),
    },
    condition: { expression: 'attribute_not_exists(PK)' } // Idempotency guard
  })
}

export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  return {
    id: ctx.result.orderId,
    userId: ctx.result.userId,
    status: ctx.result.status,
    total: ctx.result.total,
    items: ctx.result.items,
    createdAt: ctx.result.createdAt,
  }
}

4.3 Lambda Unit Resolver — When Lambda Is Correct

Use a Lambda data source when the resolver requires:

  • External API calls (payment gateway, email service, third-party REST API)
  • Complex business logic that cannot run in the APPSYNC_JS sandbox
  • Database operations against RDS, Elasticsearch, or other non-DynamoDB stores
TYPESCRIPT
// Lambda data source resolver (calls a Lambda function)
// APPSYNC_JS resolver that invokes Lambda:
export function request(ctx) {
  return {
    operation: 'Invoke',
    payload: {
      field: ctx.info.fieldName,
      arguments: ctx.args,
      identity: ctx.identity,
    }
  }
}

export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  return ctx.result
}
TYPESCRIPT
// The Lambda handler called by AppSync Lambda data source
export const handler = async (event: {
  field: string
  arguments: { input: { userId: string, items: any[] } }
  identity: { sub: string, claims: Record<string, string> }
}) => {
  // Full Node.js runtime — can call Stripe, send emails, query RDS, etc.
  const charge = await stripe.charges.create({
    amount: calculateTotal(event.arguments.input.items),
    currency: 'usd',
    customer: await getStripeCustomerId(event.arguments.input.userId),
  })
  return { chargeId: charge.id, status: charge.status }
}

4.4 HTTP Data Source Resolver

For external REST API calls without Lambda:

TYPESCRIPT
// Resolver: Query.getWeather → HTTP data source (OpenWeatherMap API)
export function request(ctx) {
  return {
    method: 'GET',
    resourcePath: `/data/2.5/weather`,
    params: {
      query: {
        q: ctx.args.city,
        appid: ctx.stash.apiKey, // Injected via BeforeMapping in a pipeline resolver
        units: 'metric',
      },
      headers: { 'Content-Type': 'application/json' },
    }
  }
}

export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  const data = JSON.parse(ctx.result.body)
  return {
    city: data.name,
    temperature: data.main.temp,
    description: data.weather[0].description,
  }
}
Two resolver paths for GetUser: (A) AppSync → Lambda unit resolver → DynamoDB (3 hops, cold-start risk, Lambda billing); (B) AppSync → DynamoDB unit resolver (2 hops, no cold start, no Lambda billing); latency and cost annotated on each
Two resolver paths for GetUser: (A) AppSync → Lambda unit resolver → DynamoDB (3 hops, cold-start risk, Lambda billing); (B) AppSync → DynamoDB unit resolver…

5. Authorization Modes

AppSync supports four authorization modes per API, configurable per operation:

Mode How it works Use case
API Key Static key in x-api-key header Public read-only data; short-lived demos
Cognito User Pool JWT from Cognito User Pool User-facing apps with Cognito identity
IAM AWS SigV4 signature Machine-to-machine; AWS service callers
Lambda Custom auth Lambda function Third-party tokens; complex auth logic
OIDC JWT from any OIDC provider Non-Cognito identity (Auth0, Okta)
TYPESCRIPT
// CDK: GraphQL API with Cognito + API Key dual auth
import { GraphqlApi, SchemaFile, AuthorizationType } from 'aws-cdk-lib/aws-appsync'
import { UserPool } from 'aws-cdk-lib/aws-cognito'

const api = new GraphqlApi(this, 'OrderApi', {
  name: 'order-api',
  schema: SchemaFile.fromAsset('schema.graphql'),
  authorizationConfig: {
    defaultAuthorization: {
      authorizationType: AuthorizationType.USER_POOL,
      userPoolConfig: { userPool },
    },
    additionalAuthorizationModes: [{
      authorizationType: AuthorizationType.API_KEY, // For public read operations
    }]
  },
})

Inside resolvers, ctx.identity provides the caller's auth context:

TYPESCRIPT
// In a Cognito-authenticated resolver
export function request(ctx) {
  // ctx.identity is populated based on the auth mode:
  const userId = ctx.identity.sub          // Cognito user UUID
  const email = ctx.identity.claims.email
  const groups = ctx.identity.claims['cognito:groups'] // Array of Cognito group names

  // Authorization check: only allow users to fetch their own orders
  if (ctx.args.userId !== userId) {
    util.unauthorized()
  }
  return get({ key: { PK: `USER#${userId}`, SK: 'PROFILE' } })
}
AppSync resolver anatomy: Schema SDL field → Resolver (APPSYNC_JS request() / response() functions) → Data Source (DynamoDB / Lambda / HTTP); what flows through each connection labeled
AppSync resolver anatomy: Schema SDL field → Resolver (APPSYNC_JS request() / response() functions) → Data Source (DynamoDB / Lambda / HTTP); what flows thro…

6. Deploying AppSync with CDK

TYPESCRIPT
// CDK: complete AppSync API with DynamoDB data source and unit resolver
import {
  GraphqlApi, SchemaFile, AuthorizationType,
  MappingTemplate, Code, FunctionRuntime,
  DynamoDbDataSource, AppsyncFunction, Resolver
} from 'aws-cdk-lib/aws-appsync'
import { Table } from 'aws-cdk-lib/aws-dynamodb'

const table = Table.fromTableName(this, 'OrderTable', 'Orders')
const api = new GraphqlApi(this, 'Api', {
  name: 'order-api',
  schema: SchemaFile.fromAsset('schema.graphql'),
  authorizationConfig: {
    defaultAuthorization: { authorizationType: AuthorizationType.USER_POOL, userPoolConfig: { userPool } }
  }
})

// Data source: DynamoDB (no Lambda in the path)
const dataSource = new DynamoDbDataSource(this, 'OrderDS', { api, table })

// Unit resolver using APPSYNC_JS runtime
new Resolver(this, 'GetUserResolver', {
  api,
  typeName: 'Query',
  fieldName: 'getUser',
  dataSource,
  runtime: FunctionRuntime.JS_1_0_0,
  code: Code.fromAsset('resolvers/Query.getUser.js'),
})

Summary

Concept Rule
AppSync is a managed execution engine Not a Lambda router for GraphQL — resolvers connect to data sources directly
Unit resolver One field, one data source, one request() / response() function pair
APPSYNC_JS sandbox No fetch(), no npm packages, no async/await — pure transformation only
Non-null SDL fields A client contract enforced at runtime — returning null for a non-null field propagates null upward
Lambda data source Use only when the operation requires network calls, external APIs, or complex business logic
Auth via ctx.identity Caller's Cognito claims, IAM principal, or custom Lambda auth context available in every resolver

What's Next

In Part 6: AppSync Advanced — Pipeline Resolvers, Subscriptions & Multi-Auth, we compose multi-step operations with pipeline resolvers, build real-time subscriptions with server-side event filtering, and enforce field-level authorization in schemas with multiple simultaneous auth modes.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#AppSync#GraphQL#AWS#Unit Resolvers#DynamoDB#APPSYNC_JS#Serverless
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.