How to resolve complex types with AppSync resolvers

Trivial resolvers help writing less code

Author's image
Tamás Sallai
3 mins

Resolving fields in GraphQL

In GraphQL, resolvers can be added for any fields. These can be top-level, such as Queries and Mutations, a type defined in the schema, or even for scalar fields for complex types.

Let's see this schema:

type User {
	id: ID!
	name: String!
}

type Group {
	id: ID!
	users: [User!]!
}

type Query {
	user(id: ID!): User
	group(id: ID!): Group
}

schema {
	query: Query
}

Here, you can define a resolver for Query.user, Query.group, as top-level resolvers, Group.users as a resolver to a complex type, and also User.id, User.name, and Group.id that resolve to scalar types.

This allows fine-grained control over the resolution process, as you can customize every part of it. For example, the Query.user can fetch a user object from the database, the Query.group fetch a group, and the Group.users can query users in a group. Then all the other fields can resolve based on the result of these resolvers.

But what if you don't want to be this granular? Maybe it's easier or more performant to fetch the group and the users in it in a single call and not break these into two operations. In that case, this multi-level resolving structure is in the way. So, is there an easy way to implement it?

Trivial resolvers

When you don't define a resolver for a field, a default one is added automatically by AppSync. These default resolvers are simple: they fetch the property of the source object that matches the field name.

Let's see an example how they work for an object!

If the Query.user resolves to an object with this structure:

{
	"id": "1",
	"name": "user 1"
}

Then the User.id can simply get the id property, and the User.name can get the name.

This is the reason why you don't need that many resolvers when the database structure matches the GraphQL schema. In that case, most of the objects have the expected fields and the trivial resolvers are enough.

But the choice is always there: you can decide to overwrite the default and implement it a different way.

One example when I had to do this is when the database stored timestamps in UNIX timestamp format (seconds from epoch) while the GraphQL schema needed AWSDateTime which is in ISO8601 format. To bridge the gap, I had to add a resolver that called the $util.time.epochMilliSecondsToISO8601 function with the source property.

Lists

The same works for lists too, in this case the source object needs to be an array. For example, if the Query.group returns this JSON:

{
	"id": "1",
	"users": [
		{
			"id": "1",
			"name": "user 1"
		},
		{
			"id": "2",
			"name": "user 2"
		},
	]
}

Then the default resolver will convert the two objects into two User types with id and name fields.

Supporting both cases

What if sometimes the Group object has users field but sometimes it doesn't? For example, maybe there is a different query that also returns a Group:

type Query {
	primaryGroup: Group
	# ...
}

Then let's say the Query.primaryGroup resolves to this object:

{
	"id": "1"
}

In this case, there is no users property so the default resolver is not able to resolve this field automatically. To support this case, you can add a Group.users resolver and check the source object ($ctx.source) if there is a users and only go to the database if it is not present.

July 5, 2022
In this article