GraphQL and Go with AppSync and Lambda

Ben Giddins
10 min readOct 8, 2019

--

At Rumple we’ve been working on building our serverless precious metals trading platform using React, GraphQL and Go, deployed on AWS. Our entire infrastructure (except for a WordPress website!) is serverless — nary an EC2 instance to be seen. The language of choice for our Lambda development is Go — it was selected for its rising popularity, familiarity of development concepts (I spent the 90s and 2000s working with C and Java), and at the time, leaderboard performance on AWS Lambda as a compiled language that didn’t have the overhead of loading a JVM. Plus I didn’t know Node.js, so there was that.

Until this project, I had only worked with REST APIs — GraphQL was a brand new thing. After running through the Apollo tutorials to get up to speed, I started looking at AWS AppSync, and the outlines of an architecture started to come together — React and React Native UX layers, communicating with GraphQL through AppSync to Lambda resolver functions that fronted an event driven backend, all implemented in Lambda. Throw in some Cognito for security, and we were off and racing. I did work on a side project that used Apollo GraphQL outside of AWS in the interim, but for our purposes, AWS is excellent.

During what has turned out to be a very long build phase (we’re in a FinCEN regulated industry, which doesn’t really lend itself to moving fast and breaking things), a design pattern has emerged for using Lambda Go functions with GraphQL. I genuinely hope that there’s something here you can use as a basis for your own projects.

The power of a Lambda function for a GraphQL resolver is the switch statement — a single resolver can service multiple queries or mutations related to a particular entity. For example, related tasks like CRUD functions for address management can be handled by a single Lambda function that resolves actions like createAddress, readAddress, updateAddress and deleteAddress. All that is required is that an AddressRequest input type in GraphQL contains all of the required parameters to manage these requests, and an AddressResponse type contains all of the required attributes to return the necessary data. Once your input or response types start getting too complex, you know its indicative of time to split the function up to be more granular. At the end of the day though, it means though is that if you have 100 queries and mutations in your schema, you can have far fewer than 100 Lambda functions to service it. Much easier to manage.

Let’s look at some examples. This is what a project directory structure looks like for illustrative purposes:

Our GraphQL approach is as follows:

  • GraphQL APIs separated by security domain— our client and admin APIs use separate Cognito pools for authentication, so we split them in two (only our client API is shown above)
  • GraphQL schema, data sources and resolvers all managed in Terraform for source control
  • queries and mutations use a Request input and Response type signature, where the request and the response structures contain all of the parameters and attributes for several related queries and mutations
  • enum lists are used to enforce sets of values, e.g. if a trade’s direction can only be BUY or SELL, an enum will be declared with just those two valid values — a trade type of QUOTE would be rejected

Our client GraphQL schema in client.graphql might look like the following:

schema {
query: Query
mutation: Mutation
}
type Query {
getAddressList: AddressResponse
readAddress(input: AddressRequest): AddressResponse
}
type Mutation {
createAddress(input: AddressRequest): AddressResponse
updateAddress(input: AddressRequest): AddressResponse
deleteAddress(input: AddressRequest): AddressResponse
}
enum AddressType {
RESIDENTIAL
BILLING
SHIPPING
MAILING
}
enum Channel {
MOBILE
WEB
}
input AddressRequest {
uuid: String
line_1: String
line_2: String
city: String
zip_postcode: String
state_province: String
country: String
address_type: AddressType
channel: Channel!
}
type Address {
uuid: String
line_1: String
line_2: String
city: String
zip_postcode: String
state_province: String
country: String
address_type: String
}
type AddressResponse {
addresses: [Address]
error: Error
}
type Error {
message: String
}

We’ve defined two queries and three mutations for our CRUD functions, two enumerators for the Address type and the UI channel we receive an invocation from, an input type for receiving an address or an address identifier, a response type for passing back one or more addresses, and an error type to allow clean error handling on the client.

These GraphQL query names, mutation names inputs, types and enums are then modeled in Go as consts, structs, and custom types. We define a local graphql package to hold these definitions — if a request or response type needs to change in GraphQL, we only need to update one struct in Go to mirror the change, along with any logic edits to support the change.

Over in Go, we represent the same schema now using Go types in graphql.go:

package graphql// Enumeration types
type AddressType string
type Channel string
// Constants
const (
// Queries
GetAddressList = "getAddressList"
ReadAddress = "readAddress"
// Mutations
CreateAddress = "createAddress"
UpdateAddress = "updateAddress"
DeleteAddress = "deleteAddress"
// Enumerators
Residential AddressType = "RESIDENTIAL"
Billing AddressType = "BILLING"
Shipping AddressType = "SHIPPING"
Mailing AddressType = "MAILING"
Web Channel = "WEB"
Mobile Channel = "MOBILE"
)
// Input
type AddressRequest struct {
UUID string `json:"uuid"`
Line1 string `json:"line_1"`
Line2 string `json:"line_2"`
City string `json:"city"`
ZipPostcode string `json:"zip_postcode"`
StateProvince string `json:"state_province"`
Country string `json:"country"`
AddressType AddressType `json:"address_type"`
Channel Channel `json:"channel"`
}
type Address struct {
UUID string `json:"uuid"`
Line1 string `json:"line_1"`
Line2 string `json:"line_2"`
City string `json:"city"`
ZipPostcode string `json:"zip_postcode"`
StateProvince string `json:"state_province"`
Country string `json:"country"`
AddressType *AddressType `json:"address_type"` // may be null
}
type AddressResponse struct {
Addresses []Address `json:"addresses"`
Error Error `json:"error"`
}
type Error struct {
Message *string `json:"message"`
}

GraphQL enum lists are added as custom types (usually a custom string type). By using these custom types in the structs, we ensure that Go will only return valid set values expected by GraphQL. Note that as an empty string in Go is not null, if the data schema allows for null values in an attribute, we should use a pointer to a custom type in a struct to signify that nulls are allowed. GraphQL cannot handle an empty string if it’s expecting an enum entry, but it can handle a null. For example, we also return a null error message if there is no error, so we need to use a pointer to a string in our Error struct, otherwise it would return empty strings rather than null.

If you have two or more entries in GraphQL enums that share the same value (e.g. NEW might be a valid state for both a trade status and a customer status), you will need to create multiple packages in Go for the schema replication, as a Go package cannot contain duplicate constant names even if they are of different types. In this instance, we would remove both the TradeStatus and CustomerStatus custom types from our graphql package, and create a trade package and customer package where they would reside. This ensures that there is no ambiguity, while striving to maintain a central definition of the GraphQL schema using Go types.

package trade                        package customertype State string                    type State string
const ( const (
New State = "NEW" New State = "NEW"
} }

These could then be referenced as trade.New and customer.New without conflict.

Rumple uses HashiCorp’s Terraform for IaC management. Terraform allows complete management of a Cognito / AppSync / Lambda deployment, but for brevity, let’s look at just the relevant parts that tie our GraphQL schema and Lambda functions together:

resource "aws_lambda_function" "client_address" {
# Function code
function_name = "client-address"
description = "Resolver for client address GraphQL operations"
filename = "lambda/address.zip"
runtime = "go1.x"
handler = "address"
source_code_hash = "${base64sha256(file("lambda/address.zip"))}"
# Environment variables
environment {
variables = {
DB = "${aws_secretsmanager_secret.database_password.name}"
}
}
# Tags
tags = {
Terraform = "true"
Usage = "GraphQL resolver"
}
# Execution role
role = "${aws_iam_role.lambda_execution.arn}"
# Basic settings
memory_size = "128"
timeout = "10"
# Network configuration
vpc_config {
subnet_ids = ["${module.vpc.private_subnets}"]
security_group_ids = ["${aws_security_group.outbound.id}"]
}
}
resource "aws_appsync_datasource" "client_address" {
api_id = "${aws_appsync_graphql_api.client.id}"
name = "client_address"
type = "AWS_LAMBDA"
service_role_arn = "${aws_iam_role.appsync_invoke_lambda.arn}"
lambda_config {
function_arn = "${aws_lambda_function.client_address.arn}"
}
}
resource "aws_appsync_resolver" "get_address_list" {
api_id = "${aws_appsync_graphql_api.client.id}"
field = "getAddressList"
type = "Query"
data_source = "${aws_appsync_datasource.client_address.name}"
request_template = <<EOF
#set( $myMap = {
"field": "getAddressList",
"user_id": $context.identity.sub,
"arguments": $context.arguments
} )
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": $util.toJson($myMap)
}
EOF
response_template = <<EOF
$util.toJson($context.result)
EOF
}

Here we’ve defined a function, a data source and a resolver in Terraform. The data source refers to our Lambda function, and there can be n resolvers per data source.

If you’re not using Terraform, you would configure this through the AppSync and Lambda web consoles.

Note that the request template is passing the following values:

"field": "getAddressList",
"user_id": $context.identity.sub,
"arguments": $context.arguments

These correlate to the parameters received by the Lambda function’s request struct — a field, which is the query or mutation name, the user_id of the authenticated Cognito user (which is a UUID string), and the arguments parameter containing JSON that reflects the AddressRequest type. The case here must match the names within the JSON tags of the Go struct.

Now let’s look inside the Go Lambda function.

// Structure for an incoming GraphQL request
// Matches the request_template in aws_appsync_resolver
type requestObj struct {
Field string `json:"field"`
UserId string `json:"user_id"`
Arguments argumentsObj `json:"arguments"`
}
// Structure for arguments received from GraphQL
type argumentsObj struct {
Input graphql.AddressRequest `json:"input"`
}

We have our expected parameters in the requestObj struct (matching the resolver’s request template), and we have an argumentsObj that contains all of the parameters passed to the GraphQL query or mutation in a parameter of type AddressRequest (defined in graphql.go).

Further down in the Lambda function, we reach our Handler:

func Handler(ctx context.Context, req requestObj) (interface{}, error) {
// Local variables
var res graphql.AddressResponse
var err error
// Process GraphQL request
switch req.Field {
case graphql.GetAddressList:
err = getAddressList(&req, &res)
case graphql.ReadAddress:
err = readAddress(&req, &res)
case graphql.CreateAddress:
err = createAddress(&req, &res)
case graphql.UpdateAddress:
err = updateAddress(&req, &res)
case graphql.DeleteAddress:
err = deleteAddress(&req)
default:
// Unknown invocation
errorMsg := "Handler() unknown GraphQL " + req.Field
err = errors.New(errorMsg)
}
// Inspect for errors
if err != nil {
errorMsg := err.Error()
res.Error.Message = &errorMsg
}
// Ensure empty slices, not nulls returned
if res.Addresses == nil {
res.Addresses = []graphql.Address{}
}
// We don't return errors, we return an error message
return res, nil
}

This is where the pattern comes together. The switch statement in the Handler examines the field parameter of all resolver calls made to the data source that this Lambda function is configured for. So in our address example, five GraphQL queries and mutations, one Lambda function. The appropriate function is called and passed the request object, and if the function needs to return data, is also passed a result object that it can modify.

All error handling is done once in the Handler() function.

Over in our React app, we define our queries and mutations as constants. I like to put these all into a single JSX file that holds related actions, and import them as needed where being used.

export const ADDRESS_LIST = `
query AddressList {
getAddressList {
addresses {
uuid
line_1
line_2
city
zip_postcode
state_province
country
address_type
}
error {
message
}
}
}`;
...export const DELETE_ADDRESS = `
mutation DeleteAddress($input: AddressRequest!) {
deleteAddress(input: $input) {
error {
message
}
}
}`;

The link between this schema definition in React and the resolvers in AppSync is the name of the query or mutation (bolded) inside of the query definition. This will match the query or mutation in the GraphQL schema uploaded to AppSync, and match the value of the const defined in Go.

I picked these two for illustration — getAddressList does not need to pass any invocation-specific parameters to the Lambda function (the current user_id is passed through the resolver mapping), so there is no input parameter. A call to it in React might look like:

import { ADDRESS_LIST } from 'schema/address';...API.graphql(graphqlOperation(ADDRESS_LIST)).then((response) => {
...

Conversely, deleteAddress needs to know which address is being deleted, so it might receive a JSON input parameter that looks like this:

import { DELETE_ADDRESS} from 'schema/address';...let myInput = {};
myInput.uuid = this.state.addressUUID;
API.graphql(graphqlOperation(DELETE_ADDRESS, {input: myInput})).then((response) => {
...

The deleteAddress mutation will receive the JSON representation of the AddressRequest type, with just the uuid parameter populated.

Try to rationalize the number of parameters in your return types — for example, getAddressList needs to return n addresses, while readAddress only needs to return 1. readAddress can return a single address in the addresses slice at addresses[0] rather than having its own standalone attributes in the response. A success condition check might look like the following:

import { READ_ADDRESS} from 'schema/address';...let myInput = {};
myInput.uuid = this.state.addressUUID;
API.graphql(graphqlOperation(READ_ADDRESS, {input: myInput})).then((response) => {
if (response.data && response.data.getAddressList && response.data.getAddressList.addresses && response.data.getAddressList.addresses.length === 1) {
// null safe inspection was successful, address found
}
}

The syntax is verbose, but this checking prevents attempting to evaluate attributes of an undefined value.

When returning responses from mutations like a save or update operation, it’s good practice to always return the item that was being mutated. For deletions, just return an error, which will be null if successful, or populated if unsuccessful.

As a practice, I return nulls in GraphQL if an attribute is empty, rather than an empty string. This can be seen in how we process errors in Go’s handler:

type Error struct {
Message *string `json:"message"` // null by default
}
...// Inspect for errors
if err != nil {
errorMsg := err.Error()
res.Error.Message = &errorMsg
}

…and how we receive errors in React:

API.graphql(graphqlOperation(DELETE_ADDRESS, {input: myInput})).then((response) => {
...
if (response.data && response.data.error && response.data.error.message) {
// non-null error received, handle it
alert(response.data.error.message);
}
}

If there is no error, this evaluation will fail, as response.data.error.message will be null, and processing will continue.

I also return empty arrays rather than null arrays, just in case there’s client code that is not null safe when handling an array.

Obviously there is a lot more to writing a Lambda function than just the Handler() method, but we can cover that in a future blog post.

Hope this gets you started!

--

--

Ben Giddins

CEO of Rumple Inc. Father. Husband. Gold bug. Crypto enthusiast. Kayaker. Fisher. More hobbies than I can poke a stick at. My workbench is always a mess.