Siddhant Deval
Siddhant Deval
backend20 min read

AppSync Advanced: Pipeline Resolvers, Subscriptions & Multi-Auth

Pipeline resolvers replace Lambda-based orchestration for multi-step data operations — a DynamoDB fetch, Lambda enrich, and EventBridge write pipeline executes inside AppSync without a dedicated orchestrator function. This article covers pipeline function composition, ctx.stash for inter-function state, WebSocket subscription lifecycle with server-side filters, and field-level auth with multiple simultaneous auth modes.

AppSync Advanced: Pipeline Resolvers, Subscriptions & Multi-Auth

Every AWS primitive is a tradeoff surface, not a feature toggle. A senior engineer does not add a Lambda function to orchestrate a DynamoDB auth check, a business logic transformation, and an EventBridge event emission as three sequential steps — they use a pipeline resolver, which executes those steps inside AppSync's managed engine without a dedicated orchestrator Lambda, without its cold-start risk, and without paying three distinct compute billing surfaces. This article covers AppSync's composition primitives: pipeline resolvers for multi-step operations, WebSocket subscriptions with server-side event filtering, and multi-auth schemas where Cognito users, IAM principals, and API key consumers share one schema with field-level access control.


1. Pipeline Resolvers — Multi-Step Operations Without a Coordinator Lambda

A pipeline resolver is a resolver that executes a sequence of AppSync Functions (pipeline functions) — each connecting to an independent data source. The execution is sequential, each function receiving the previous function's output.

1.1 Architecture

Pipeline Resolver execution:
  BeforeMapping (optional)
    ↓
  Pipeline Function 1  →  Data Source A (e.g., DynamoDB — ownership check)
    ↓  ctx.stash
  Pipeline Function 2  →  Data Source B (e.g., Lambda — business logic)
    ↓  ctx.stash
  Pipeline Function 3  →  Data Source C (e.g., EventBridge — side effect)
    ↓
  AfterMapping (optional) → GraphQL response

The key difference from a unit resolver: each pipeline function is an independently deployable AppSync Function with its own data source, its own request() / response() functions, and its own error handling.

1.2 ctx.stash — The Correct Communication Channel

TYPESCRIPT
// ❌ Wrong: mutating ctx.args to pass data between functions
// Function 1:
export function request(ctx) {
  ctx.args.ownerId = ctx.identity.sub // WRONG — corrupts args for all subsequent functions
  return get({ key: { PK: `ORDER#${ctx.args.orderId}` } })
}

// ✅ Correct: use ctx.stash for inter-function state
// ctx.stash is a plain object that persists across all functions in the pipeline
// ctx.args remains unchanged throughout the pipeline

// Pipeline Function 1: DynamoDB GetItem — ownership check
export function request(ctx) {
  return get({ key: { PK: `ORDER#${ctx.args.orderId}`, SK: 'METADATA' } })
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  if (!ctx.result) util.error('Order not found', 'NotFound')
  if (ctx.result.userId !== ctx.identity.sub) util.unauthorized()

  // Store result in stash for downstream functions
  ctx.stash.order = ctx.result
  return ctx.result
}
TYPESCRIPT
// Pipeline Function 2: Lambda — business logic (payment processing)
export function request(ctx) {
  // ctx.stash.order set by Function 1 — available here
  return {
    operation: 'Invoke',
    payload: {
      action: 'processPayment',
      orderId: ctx.stash.order.orderId,
      amount: ctx.stash.order.total,
      userId: ctx.identity.sub,
    }
  }
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  ctx.stash.chargeId = ctx.result.chargeId
  return ctx.result
}
TYPESCRIPT
// Pipeline Function 3: HTTP data source — EventBridge PutEvents
export function request(ctx) {
  return {
    method: 'POST',
    resourcePath: '/',
    params: {
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        Entries: [{
          Source: 'com.myapp.orders',
          DetailType: 'OrderConfirmed',
          Detail: JSON.stringify({
            orderId: ctx.stash.order.orderId,
            chargeId: ctx.stash.chargeId,
            userId: ctx.identity.sub,
          }),
          EventBusName: 'myapp-events',
        }]
      })
    }
  }
}
export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  // Pipeline complete — return the stashed order for the GraphQL response
  return ctx.stash.order
}
Performance / Safety Warning

Pipeline resolvers stop execution on the first function failure — subsequent functions do not run. There is no automatic rollback of completed steps. If Function 2 (payment) succeeds but Function 3 (EventBridge) fails, the payment charge has been created but no downstream event was emitted. Design each step to be idempotent so that pipeline retries (AppSync will retry the entire pipeline on transient failures) do not double-apply side effects.

1.3 CDK: Pipeline Resolver Configuration

TYPESCRIPT
import {
  GraphqlApi, Code, FunctionRuntime,
  AppsyncFunction, Resolver, DynamoDbDataSource, LambdaDataSource, HttpDataSource
} from 'aws-cdk-lib/aws-appsync'

// Three data sources — one per pipeline function
const dynamoDS = new DynamoDbDataSource(this, 'OrderDS', { api, table })
const lambdaDS = new LambdaDataSource(this, 'PaymentDS', { api, lambdaFunction: paymentFn })
const eventBridgeDS = new HttpDataSource(this, 'EventDS', {
  api,
  endpoint: `https://events.${Stack.of(this).region}.amazonaws.com`,
  authorizationConfig: { signingRegion: Stack.of(this).region, signingServiceName: 'events' }
})

// Three AppSync Functions
const ownershipCheckFn = new AppsyncFunction(this, 'OwnershipCheck', {
  api, dataSource: dynamoDS,
  name: 'OwnershipCheck',
  runtime: FunctionRuntime.JS_1_0_0,
  code: Code.fromAsset('resolvers/functions/ownershipCheck.js'),
})
const paymentFn2 = new AppsyncFunction(this, 'ProcessPayment', {
  api, dataSource: lambdaDS,
  name: 'ProcessPayment',
  runtime: FunctionRuntime.JS_1_0_0,
  code: Code.fromAsset('resolvers/functions/processPayment.js'),
})
const emitEventFn = new AppsyncFunction(this, 'EmitEvent', {
  api, dataSource: eventBridgeDS,
  name: 'EmitEvent',
  runtime: FunctionRuntime.JS_1_0_0,
  code: Code.fromAsset('resolvers/functions/emitEvent.js'),
})

// Pipeline resolver: wires functions in sequence
new Resolver(this, 'ConfirmOrderResolver', {
  api,
  typeName: 'Mutation',
  fieldName: 'confirmOrder',
  runtime: FunctionRuntime.JS_1_0_0,
  pipelineConfig: [ownershipCheckFn, paymentFn2, emitEventFn],
  code: Code.fromInline(`
    export function request(ctx) { return {} }
    export function response(ctx) { return ctx.prev.result }
  `)
})

2. Subscriptions — Real-Time Without a WebSocket Server

AppSync manages the full WebSocket subscription lifecycle: connection, authentication, event routing, server-side filtering, and disconnection. You write no WebSocket server code.

2.1 Subscription Lifecycle

1. Client connects to AppSync WebSocket endpoint
2. AppSync validates auth (Cognito JWT, API key, Lambda authorizer)
3. Client subscribes: subscription { onOrderStatusChanged(userId: "u42") { id, status } }
4. AppSync registers the subscription — stores subscription context
5. Mutation fires: updateOrderStatus(orderId: "o99", status: SHIPPED)
6. AppSync executes the mutation resolver
7. Server-side filter evaluates: does this event match subscription { userId: "u42" }?
8. Filter passes → AppSync pushes { id: "o99", status: "SHIPPED" } over WebSocket to client
9. Client disconnects → AppSync removes subscription registration

2.2 SDL Subscription Declaration

GRAPHQL
type Subscription {
  onOrderStatusChanged(userId: ID!): Order
    @aws_subscribe(mutations: ["updateOrderStatus", "cancelOrder"])
  # This subscription fires when updateOrderStatus OR cancelOrder mutations are called
  # The userId argument is used in server-side filter expressions
}

2.3 Server-Side Subscription Filters

Without filters, every subscriber receives every mutation event. Server-side filters evaluate before the WebSocket push — only subscribers whose filter expression matches receive the event.

TYPESCRIPT
// In the Mutation.updateOrderStatus resolver:
// After updating DynamoDB, set a subscription filter on the mutation's response
// This filter controls which active subscriptions receive the push

export function response(ctx) {
  if (ctx.error) util.error(ctx.error.message, ctx.error.type)
  const order = ctx.result

  // Set filter: only push to subscribers whose subscription userId matches this order's userId
  extensions.setSubscriptionFilter(
    util.transform.toSubscriptionFilter({
      or: [
        { userId: { eq: order.userId } },  // Push to the order owner
        { userId: { eq: 'admin' } },        // Also push to admin subscribers
      ]
    })
  )

  return order
}
TYPESCRIPT
// Subscription resolver: validates the subscriber is authorized
export function request(ctx) {
  // ctx.identity.sub = authenticated Cognito user's UUID
  // Only allow users to subscribe to their own order updates
  if (ctx.args.userId !== ctx.identity.sub) {
    util.unauthorized()
  }
  return {} // empty return for subscription request function
}

export function response(ctx) {
  return ctx.result
}
Crucial Requirement

Server-side subscription filters are set in the mutation resolver, not the subscription resolver. The subscription resolver validates that the subscriber is authorized to subscribe. The mutation resolver controls which subscribers receive each event by setting extensions.setSubscriptionFilter() in the mutation's response() function.


3. Multi-Auth Schemas — Field-Level Access Control

AppSync supports multiple authorization modes active simultaneously on one schema. Field-level directives control which auth mode can access which field.

3.1 Schema-Level Auth Directives

GRAPHQL
# Multi-auth schema: Cognito + IAM + API Key simultaneously

type Query {
  # Cognito users and IAM principals can get user profiles
  getUser(id: ID!): User @aws_cognito_user_pools @aws_iam

  # Public leaderboard — API key access for unauthenticated clients
  getLeaderboard: [LeaderboardEntry!]! @aws_api_key @aws_cognito_user_pools
}

type Mutation {
  # Only Cognito users can create orders (not IAM service accounts)
  createOrder(input: CreateOrderInput!): Order! @aws_cognito_user_pools

  # Internal service mutations — IAM only (service-to-service, not end-user callable)
  internalUpdateInventory(productId: ID!, delta: Int!): Boolean! @aws_iam
}

type User {
  id: ID!
  email: String!
  name: String
  # Sensitive field: only accessible to Cognito users (not IAM or API key)
  paymentMethod: PaymentMethod @aws_cognito_user_pools
  # Admin-only field: requires Cognito + Admin group membership
  internalNotes: String @aws_auth(cognito_groups: ["Admin"])
}

3.2 Group-Level Authorization

TYPESCRIPT
// In a resolver: check Cognito group membership
export function request(ctx) {
  const groups: string[] = ctx.identity.claims['cognito:groups'] ?? []

  // Protect admin-only mutations inside the resolver (defense in depth)
  if (!groups.includes('Admin')) {
    util.unauthorized()
  }
  return get({ key: { PK: `USER#${ctx.args.id}` } })
}

4. Caching — Per-Resolver vs Full-Request

AppSync caching reduces DynamoDB reads and Lambda invocations for frequently accessed data.

Cache type Cache key Effective for
Full-request Entire GraphQL query + variables + auth context Only when identical requests from identical auth contexts repeat frequently
Per-resolver Resolver type + field + arguments Most authenticated APIs — cache individual field resolvers independently
TYPESCRIPT
// CDK: enable per-resolver caching
import { CachingConfig, CachingBehavior } from 'aws-cdk-lib/aws-appsync'

const api = new GraphqlApi(this, 'Api', {
  name: 'order-api',
  schema: SchemaFile.fromAsset('schema.graphql'),
  // API-level cache configuration (required to enable per-resolver caching)
})

// Enable caching on the AppSync API
api.addSchemaDependency(new CfnApiCache(this, 'ApiCache', {
  apiId: api.apiId,
  type: 'SMALL',                         // Instance size (SMALL/MEDIUM/LARGE/etc.)
  ttl: 300,                              // Default TTL: 300 seconds
  apiCachingBehavior: 'PER_RESOLVER_CACHING', // Cache per resolver, not full request
  atRestEncryptionEnabled: true,
  transitEncryptionEnabled: true,
}))
Pro Tip & Optimization

Full-request caching requires the same query string, same variables, and same auth context to produce a cache hit. For authenticated APIs where ctx.identity.sub differs per user, full-request caching produces virtually zero hits. Per-resolver caching on high-read-ratio fields (product catalog, leaderboard, public config) dramatically reduces DynamoDB read costs with minimal code change.

Pipeline resolver execution: BeforeMapping → Function1 (DynamoDB, result stored in ctx.stash) → Function2 (Lambda, chargeId stored in ctx.stash) → Function3 (EventBridge) → AfterMapping; ctx.stash values labeled at each boundary
Pipeline resolver execution: BeforeMapping → Function1 (DynamoDB, result stored in ctx.stash) → Function2 (Lambda, chargeId stored in ctx.stash) → Function3…
Multi-auth schema topology: Cognito User Pool (cyan), IAM (violet), API Key (dim) connected to field-level directive mapping; access control matrix per operation type shown as a grid
Multi-auth schema topology: Cognito User Pool (cyan), IAM (violet), API Key (dim) connected to field-level directive mapping; access control matrix per opera…

Summary

Concept Rule
Pipeline resolver Sequential execution, stops on failure — no automatic rollback; each step must be idempotent
ctx.stash Correct inter-function state channel — never mutate ctx.args between pipeline functions
Subscription filters Set in mutation resolver with extensions.setSubscriptionFilter() — evaluated before push
Multi-auth directives @aws_cognito_user_pools, @aws_iam, @aws_api_key per field/operation
Per-resolver caching Outperforms full-request for authenticated APIs — cache individual field resolvers

What's Next

In Part 7: DynamoDB Data Modeling — Access-Pattern-First Design, we shift from the API layer to the data layer: why entity-first DynamoDB modeling always leads to GSI sprawl, and how enumerating all queries before writing any key schema is the discipline that eliminates retrofitting migrations.

Research & Synthesis Note

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

#AppSync#Pipeline Resolvers#GraphQL Subscriptions#Multi-Auth#APPSYNC_JS#WebSocket#Serverless
Siddhant Deval

Written by Siddhant Deval

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