March 13, 2025
AWS AppSync pipeline resolvers enable multi-step processing for GraphQL field resolution. However, as GraphQL schemas grow, several challenges emerge:
This approach organizes resolvers by mirroring the GraphQL schema structure in your filesystem:
pipelines/
├── {TypeName}/
│ └── {FieldName}/
│ ├── meta.json
│ ├── 0.resolver.js
│ └── 1.resolver.js (optional additional functions)
For example, this GraphQL query:
type Query {
listRestaurants(input: ListRestaurantsInput!): RestaurantPage!
}
Maps to:
pipelines/Query/listRestaurants/
Each resolver function is a numbered JavaScript file implementing AppSync’s request/response pattern:
// pipelines/Query/listRestaurants/0.resolver.js
export function request(ctx) {
const { args } = ctx;
return {
operation: 'Query',
query: {
expression: '#location = :location',
expressionNames: { '#location': 'location' },
expressionValues: { ':location': args.input.location }
},
};
}
export function response(ctx) {
return ctx.result;
}
A meta.json
file specifies the data source configuration. Each element of the dataSourceConfigs
array defines the data source for the resolver with the same index. The example below specifies that the resolver defined by the file 0.resolver.js
uses a DynamoDB data source and requires permissions for the RestaurantsTable
.
// pipelines/Query/listRestaurants/meta.json
{
"dataSourceConfigs": [
{
"type": "ddb",
"tableArns": [
"arn:aws:dynamodb:us-east-1:123456789012:table/RestaurantsTable"
]
}
]
}
The infrastructure code scans the directory structure to register resolvers:
export function defineResolvers({ scope, api }) {
const typeNamePaths = globSync(path.join(__dirname, `./pipelines/*`), { withFileTypes: true });
for (const typeNamePath of typeNamePaths) {
if (!typeNamePath.isDirectory()) continue;
const fieldNamePaths = globSync(`${typeNamePath.fullpath()}/*`, { withFileTypes: true });
for (const fieldNamePath of fieldNamePaths) {
if (!fieldNamePath.isDirectory()) continue;
definePipelineResolver({
scope,
api,
typeName: typeNamePath.name,
fieldName: fieldNamePath.name,
path: fieldNamePath,
});
}
}
}
For each resolver, the infrastructure code scans the resolver directory for {n}.resolver.js
source files and the meta.json
file:
function definePipelineResolver({
scope,
api,
typeName,
fieldName,
path,
}) {
const pipelineName = `${typeName}${upperFirst(fieldName)}Pipeline`;
const functionDefs = [];
let meta = null;
// Scan resolver directory for files and configuration
for (const child of path.readdirSync()) {
if (child.isDirectory()) continue;
const match = child.name.match(/^([0-9]+)\.resolver\.js$/i);
if (match) {
const index = +match[1];
functionDefs.push({ index, path: child });
} else if (child.name === 'meta.json') {
meta = JSON.parse(fs.readFileSync(child.fullpath(), 'utf8'));
}
}
// Sort functions by numerical prefix
const sortedFunctionDefs = _.sortBy(functionDefs, ({ index }) => index);
// Create pipeline configuration
const pipelineConfig = sortedFunctionDefs.map(({ path, index }) => {
const dataSourceConfig = meta.dataSourceConfigs[index];
const dataSource = defineDataSource({
scope,
name: `${pipelineName}DS${index}`,
api,
config: dataSourceConfig
});
return defineFunction({
name: `${pipelineName}Function${index}`,
path,
dataSource,
scope,
api,
});
});
// Register the AppSync resolver
new appsync.Resolver(scope, pipelineName, {
api,
typeName,
fieldName,
pipelineConfig,
code: Code.fromInline(`
function request(ctx) { return ctx; }
function response(ctx) { return ctx.result; }
export { request, response };
`),
runtime: FunctionRuntime.JS_1_0_0,
});
}
Function to create data sources with appropriate IAM permissions:
function defineDdbDataSourceRole({
scope,
name,
tableArns,
}: {
scope: Construct;
name: string;
tableArns: string[];
}): Role | undefined {
return new iam.Role(scope, name, {
assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'),
inlinePolicies: {
AccessDynamoDbTables: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
actions: ['dynamodb:GetItem', 'dynamodb:Query', 'dynamodb:Scan' /* add actions as needed by resolvers */],
resources: tableArns.reduce((collector: string[], tableArn: string) => {
collector.push(tableArn);
collector.push(`${tableArn}/index/*`);
return collector;
}, []),
}),
],
}),
},
});
}
function defineDataSource({
scope,
name,
api,
config,
}: {
scope: Construct;
name: string;
api: IGraphqlApi;
config: { type: string; tableArns: string[] };
}): BaseDataSource {
if (config.type === 'ddb') {
const serviceRole = defineDdbDataSourceRole({
scope,
name: `${name}ServiceRole`,
tableArns: config.tableArns,
});
return new appsync.DynamoDbDataSource(scope, name, {
api,
table: dynamodb.Table.fromTableArn(scope, `${name}Table`, config.tableArns[0]),
serviceRole,
});
}
// Implement other types of data sources as needed
throw new Error(`Unsupported data source type: ${config.type}`);
}
Integrate the resolver mapping into your CDK stack:
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as path from 'path';
import * as appsync from 'aws-cdk-lib/aws-appsync';
import { defineResolvers } from './resolvers';
export class ApiStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
const graphqlApi = new appsync.GraphqlApi(this, 'GraphqlApi', {
name: 'Api',
definition: appsync.Definition.fromSchema(
appsync.SchemaFile.fromAsset(path.join(__dirname, './shared/gql/schema.gql'))
),
});
// Register all resolvers from the directory structure
defineResolvers({
scope: this,
api: graphqlApi,
});
}
}
This implementation relies on several key libraries and frameworks:
AWS CDK (Cloud Development Kit) - Infrastructure as code framework that enables defining AWS resources using TypeScript.
Constructs - Programming model for composing cloud resources.
Lodash - JavaScript utility library used for its array and object manipulation functions.
_.sortBy()
for sorting function definitions by indexNode.js Built-ins - Native Node.js modules used for filesystem operations.
Glob - Pattern matching library for directory scanning.
globSync
function used for finding files matching patternsThese dependencies should be included in your package.json
file, except for the Node.js built-ins which are available by default in Node.js environments.
0.resolver.js
) explicitly reflect execution order.meta.json
files.This directory-based approach creates a self-documenting system where filesystem structure reflects GraphQL schema organization. The pattern maintains clarity even as APIs scale to hundreds of fields and multiple data sources, while enabling automated infrastructure provisioning through AWS CDK.
By establishing this convention, team members can quickly locate, understand, and modify resolvers without hunting through disparate files or console UIs. The result is a maintainable, scalable architecture for complex AWS AppSync GraphQL APIs.
Written by Daniel Worsnup. All my links.