The GraphQL schema is the foundation of any GraphQL server implementation. Each GraphQL API is defined by a single schema that contains types and fields describing how the data from requests will be populated. The data flowing through your API and the operations performed must be validated against the schema.
In general, the GraphQL type system
AWS AppSync allows you to define and configure GraphQL schemas. The following section describes how to create GraphQL schemas from scratch using AWS AppSync's services.
Structuring a GraphQL Schema
Tip
We recommend reviewing the Schemas section before continuing.
GraphQL is a powerful tool for implementing API services. According to GraphQL's website
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools."
This section covers the very first part of your GraphQL implementation, the schema. Using the quote above, a schema plays the role of "providing a complete and understandable description of the data in your API". In other words, a GraphQL schema is a textual representation of your service's data, operations, and the relations between them. The schema is considered the main entry point for your GraphQL service implementation. Unsurprisingly, it's often one of the first things you make in your project. We recommend reviewing the Schemas section before continuing.
To quote the Schemas section, GraphQL schemas are written in the Schema Definition Language (SDL). SDL is composed of types and fields with an established structure:
-
Types: Types are how GraphQL defines the shape and behavior of the data. GraphQL supports a multitude of types that will be explained later in this section. Each type that's defined in your schema will contain its own scope. Inside the scope will be one or more fields that can contain a value or logic that will be used in your GraphQL service. Types fill many different roles, the most common being objects or scalars (primitive value types).
-
Fields: Fields exist within the scope of a type and hold the value that's requested from the GraphQL service. These are very similar to variables in other programming languages. The shape of the data you define in your fields will determine how the data is structured in a request/response operation. This allows developers to predict what will be returned without knowing how the backend of the service is implemented.
The simplest schemas will contain three different data categories:
-
Schema roots: Roots define the entry points of your schema. It points to the fields that will be performing some operation on the data like adding, deleting, or modifying something.
-
Types: These are base types that are used to represent the shape of the data. You can almost think of these as objects or abstract representations of something with defined characteristics. For example, you could make a
Person
object that represents a person in a database. Each person's characteristics will be defined inside thePerson
as fields. They can be anything like the person's name, age, job, address, etc. -
Special object types: These are the types that define the behavior of the operations in your schema. Each special object type is defined once per schema. They are first placed in the schema root, then defined in the schema body. Each field in a special object type defines a single operation to be implemented by your resolver.
To put this into perspective, imagine you're creating a service that stores authors and the books they've written. Each author has a name and an array of books they've authored. Each book has a name and a list of associated authors. We also want the ability to add or retrieve books and authors. A simple UML representation of this relationship may look like this:

In GraphQL, the entities Author
and Book
represent two different object types in
your schema:
type Author {
}
type Book {
}
Author
contains authorName
and Books
, while Book
contains bookName
and Authors
. These can be represented as the fields within the
scope of your types:
type Author {
authorName: String
Books: [Book]
}
type Book {
bookName: String
Authors: [Author]
}
As you can see, the type representations are very close to the diagram. However, the methods are where it gets a bit trickier. These will be placed in one of a few special object types as a field. Their special object categorization depends on their behavior. GraphQL contains three fundamental special object types: queries, mutations, and subscriptions. For more information, see Special objects.
Because getAuthor
and getBook
are both requesting data, they will be placed in a
Query
special object type:
type Author {
authorName: String
Books: [Book]
}
type Book {
bookName: String
Authors: [Author]
}
type Query {
getAuthor(authorName: String): Author
getBook(bookName: String): Book
}
The operations are linked to the query, which itself is linked to the schema. Adding a schema root will
define the special object type (Query
in this case) as one of your entry points. This can be
done using the schema
keyword:
schema {
query: Query
}
type Author {
authorName: String
Books: [Book]
}
type Book {
bookName: String
Authors: [Author]
}
type Query {
getAuthor(authorName: String): Author
getBook(bookName: String): Book
}
Looking at the final two methods, addAuthor
and addBook
are adding data to your
database, so they will be defined in a Mutation
special object type. However, from the Types
page, we also know that inputs directly referencing Objects aren't allowed because they're strictly output
types. In this case, we can't use Author
or Book
, so we need to make an input type
with the same fields. In this example, we added AuthorInput
and BookInput
, both of
which accept the same fields of their respective types. Then, we create our mutation using the inputs as our
parameters:
schema {
query: Query
mutation: Mutation
}
type Author {
authorName: String
Books: [Book]
}
input AuthorInput {
authorName: String
Books: [BookInput]
}
type Book {
bookName: String
Authors: [Author]
}
input BookInput {
bookName: String
Authors: [AuthorInput]
}
type Query {
getAuthor(authorName: String): Author
getBook(bookName: String): Book
}
type Mutation {
addAuthor(input: [BookInput]): Author
addBook(input: [AuthorInput]): Book
}
Let's review what we just did:
-
We created a schema with the
Book
andAuthor
types to represent our entities. -
We added the fields containing the characteristics of our entities.
-
We added a query to retrieve this information from the database.
-
We added a mutation to manipulate data in the database.
-
We added input types to replace our object parameters in the mutation to comply with GraphQL's rules.
-
We added the query and mutation to our root schema so that the GraphQL implementation understands the root type location.
As you can see, the process of creating a schema takes a lot of concepts from data modeling (especially database modeling) in general. You can think of the schema as fitting the shape of the data from the source. It also serves as the model that the resolver will implement. In the following, sections, you'll learn how to make a schema using various AWS-backed tools and services.
Note
The examples in the following sections are not meant to run in a real application. They are only there to showcase the commands so you can build your own applications.
Creating schemas
Your schema will be in a file called schema.graphql
. AWS AppSync allows users to create new
schemas for their GraphQL APIs using various methods. In this example, we'll be creating a blank API along
with a blank schema.
-
Sign in to the AWS Management Console and open the AppSync console
. -
In the Dashboard, choose Create API.
-
Under API options, choose GraphQL APIs, Design from scratch, then Next.
-
For API name, change the prepopulated name to what your application needs.
-
For contact details, you can enter a point of contact to identify a manager for the API. This is an optional field.
-
Under Private API configuration, you can enable private API features. A private API can only be accessed from a configured VPC endpoint (VPCE). For more information, see Private APIs.
We don't recommend enabling this feature for this example. Choose Next after reviewing your inputs.
-
-
Under Create a GraphQL type, you can choose to create a DynamoDB table to use as a data source or skip this and do it later.
For this example, choose Create GraphQL resources later. We will be creating a resource in a separate section.
-
Review your inputs, then choose Create API.
-
-
You will be in the dashboard of your specific API. You can tell because the API's name will be at the top of the dashboard. If this isn't the case, you can select APIs in the Sidebar, then choose your API in the APIs dashboard.
-
In the Sidebar underneath your API's name, choose Schema.
-
-
In the Schema editor, you can configure your
schema.graphql
file. It may be empty or filled with types generated from a model. On the right, you have the Resolvers section for attaching resolvers to your schema fields. We won't be looking at resolvers in this section.
Adding types to schemas
Now that you've added your schema, you can start adding both your input and output types. Note that the types here shouldn't be used in real code; they're just examples to help you understand the process.
First, we'll create an object type. In real code, you don't have to start with these types. You can make any type you want at any time so long as you follow GraphQL's rules and syntax.
Note
These next few sections will be using the schema editor, so keep this open.
-
You can create an object type using the
type
keyword along with the type's name:type
Type_Name_Goes_Here
{}Inside the type's scope, you can add fields that represent the object's characteristics:
type
Type_Name_Goes_Here
{ # Add fields here }Here's an example:
type
Obj_Type_1
{id: ID! title: String date: AWSDateTime
}Note
In this step, we added a generic object type with a required
id
field stored asID
, atitle
field stored as aString
, and adate
field stored as anAWSDateTime
. To see a list of types and fields and what they do, see Schemas. To see a list of scalars and what they do, see the Type reference.
The object typeAWSDateTime
in
addition to the base GraphQL scalars. Also, any field that ends in an exclamation point is required.
The ID
scalar type in particular is a unique identifier that can be either
String
or Int
. You can control these in your resolver code for automatic
assignment.
There are similarities between special object types like Query
and "regular" object types
like the example above in that they both use the type
keyword and are considered objects.
However, for the special object types (Query
, Mutation
, and
Subscription
), their behavior is vastly different because they are exposed as the entry
points for your API. They're also more about shaping operations rather than data. For more information, see
The query and mutation
types
On the topic of special object types, the next step could be to add one or more of them to perform operations on the shaped data. In a real scenario, every GraphQL schema must at least have a root query type for requesting data. You can think of the query as one of the entry points (or endpoints) for your GraphQL server. Let's add a query as an example.
-
To create a query, you can simply add it to the schema file like any other type. A query would require a
Query
type and an entry in the root like this:schema { query:
Name_of_Query
} typeName_of_Query
{ # Add field operation here }Note that
Name_of_Query
in a production environment will simply be calledQuery
in most cases. We recommend keeping it at this value. Inside the query type, you can add fields. Each field will perform an operation in the request. As a result, most, if not all, of these fields will be attached to a resolver. However, we're not concerned with that in this section. Regarding the format of the field operation, it might look like this:Name_of_Query(params): Return_Type
# version with paramsName_of_Query: Return_Type
# version without paramsHere's an example:
schema { query: Query } type Query {
getObj: [Obj_Type_1]
} type Obj_Type_1 { id: ID! title: String date: AWSDateTime }Note
In this step, we added a
Query
type and defined it in ourschema
root. OurQuery
type defined agetObj
field that returns a list ofObj_Type_1
objects. Note thatObj_Type_1
is the object of the previous step. In production code, your field operations will normally be working with data shaped by objects likeObj_Type_1
. In addition, fields likegetObj
will normally have a resolver to perform the business logic. That will be covered in a different section.As an additional note, AWS AppSync automatically adds a schema root during exports, so technically you don't have to add it directly to the schema. Our service will automatically process duplicate schemas. We're adding it here as a best practice.
You've now seen an example of creating both objects and special objects (queries). You've also seen how
these can be interconnected to describe data and operations. You can have schemas with only the data
description and one or more queries. However, we'd like to add another operation to add data to the data
source. We'll add another special object type called Mutation
that modifies data.
-
A mutation will be called
Mutation
. LikeQuery
, the field operations insideMutation
will describe an operation and will be attached to a resolver. Also, note that we need to define it in theschema
root because it's a special object type. Here's an example of a mutation:schema {
mutation: Name_of_Mutation
} typeName_of_Mutation
{ # Add field operation here }A typical mutation will be listed in the root like a query. The mutation is defined using the
type
keyword along with the name.Name_of_Mutation
will usually be calledMutation
, so we recommend keeping it that way. Each field will also perform an operation. Regarding the format of the field operation, it might look like this:Name_of_Mutation(params): Return_Type # version with params Name_of_Mutation: Return_Type # version without params
Here's an example:
schema { query: Query
mutation: Mutation
} type Obj_Type_1 { id: ID! title: String date: AWSDateTime } type Query { getObj: [Obj_Type_1] } type Mutation {addObj(id: ID!, title: String, date: AWSDateTime): Obj_Type_1
}Note
In this step, we added a
Mutation
type with anaddObj
field. Let's summarize what this field does:addObj(id: ID!, title: String, date: AWSDateTime): Obj_Type_1
addObj
is using theObj_Type_1
object to perform an operation. This is apparent due to the fields, but the syntax proves this in the: Obj_Type_1
return type. InsideaddObj
, it's accepting theid
,title
, anddate
fields from theObj_Type_1
object as parameters. As you may see, it looks a lot like a method declaration. However, we haven't described the behavior of our method yet. As stated earlier, the schema is only there to define what the data and operations will be and not how they operate. Implementing the actual business logic will come later when we create our first resolvers.Once you're done with your schema, there's an option to export it as a
schema.graphql
file. In the Schema editor, you can choose Export schema to download the file in a supported format.As an additional note, AWS AppSync automatically adds a schema root during exports, so technically you don't have to add it directly to the schema. Our service will automatically process duplicate schemas. We're adding it here as a best practice.
Optional considerations - Using enums as statuses
At this point, you know how to make a basic schema. However, there are many things you could add to increase the schema's functionality. One common thing found in applications is the use of enums as statuses. You can use an enum to force a specific value from a set of values to be chosen when called. This is good for things that you know will not change drastically over long periods of time. Hypothetically speaking, we could add an enum that returns the status code or String in the response.
As an example, let's assume we're making a social media app that's storing a user's post data in the
backend. Our schema contains a Post
type that represents an individual post's data:
type Post {
id: ID!
title: String
date: AWSDateTime
poststatus: PostStatus
}
Our Post
will contain a unique id
, post title
, date
of
posting, and an enum called PostStatus
that represents the post's state as it's processed by
the app. For our operations, we'll have a query that returns all post data:
type Query {
getPosts: [Post]
}
We'll also have a mutation that adds posts to the data source:
type Mutation {
addPost(id: ID!, title: String, date: AWSDateTime, poststatus: PostStatus): Post
}
Looking at our schema, the PostStatus
enum could have several statuses. We might want the
three basic states called success
(post successfully processed), pending
(post
being processed), and error
(post unable to be processed). To add the enum, we could do
this:
enum PostStatus {
success
pending
error
}
The full schema might look like this:
schema {
query: Query
mutation: Mutation
}
type Post {
id: ID!
title: String
date: AWSDateTime
poststatus: PostStatus
}
type Mutation {
addPost(id: ID!, title: String, date: AWSDateTime, poststatus: PostStatus): Post
}
type Query {
getPosts: [Post]
}
enum PostStatus {
success
pending
error
}
If a user adds a Post
in the application, the addPost
operation will be called
to process that data. As the resolver attached to addPost
processes the data, it will
continually update the poststatus
with the status of the operation. When queried, the
Post
will contain the final status of the data. Keep in mind, we're only describing how we
want the data to work in the schema. We're assuming a lot about the implementation of our resolver(s), which
will implement the actual business logic for handling the data to fulfill the request.
Optional considerations - Subscriptions
Subscriptions in AWS AppSync are invoked as a response to a mutation. You configure this with a
Subscription
type and @aws_subscribe()
directive in the schema to denote which
mutations invoke one or more subscriptions. For more information about configuring subscriptions, see Real-time
data.
Optional considerations - Relations and
pagination
Suppose you had a million Posts
stored in a DynamoDB table, and you wanted to return some of
that data. However, the example query given above only returns all posts. You wouldn’t want to fetch all of
these every time you made a request. Instead, you would want to paginate
-
In the
getPosts
field, add two input arguments:nextToken
(iterator) andlimit
(iteration limit). -
Add a new
PostIterator
type containingPosts
(retrieves the list ofPost
objects) andnextToken
(iterator) fields. -
Change
getPosts
so that it returnsPostIterator
and not a list ofPost
objects.
schema {
query: Query
mutation: Mutation
}
type Post {
id: ID!
title: String
date: AWSDateTime
poststatus: PostStatus
}
type Mutation {
addPost(id: ID!, title: String, date: AWSDateTime, poststatus: PostStatus): Post
}
type Query {
getPosts(limit: Int, nextToken: String): PostIterator
}
enum PostStatus {
success
pending
error
}
type PostIterator {
posts: [Post]
nextToken: String
}
The PostIterator
type allows you to return a portion of the list of Post
objects
and a nextToken
for getting the next portion. Inside PostIterator
, there is a list
of Post
items ([Post]
) that is returned with a pagination token
(nextToken
). In AWS AppSync, this would be connected to Amazon DynamoDB through a resolver and
automatically generated as an encrypted token. This converts the value of the limit
argument to
the maxResults
parameter and the nextToken
argument to the
exclusiveStartKey
parameter. For examples and the built-in template samples in the
AWS AppSync console, see Resolver reference
(JavaScript).