Architecture - serlo/documentation GitHub Wiki
This page explains the basic architecture the API is built upon. It explains the different types that are used in the GraphQl schemas as well as the rest of the software that is written in TypeScript. This article assumes you already know what Apollo Server is, have studied the most important articles of the Apollo Server documentation (as given in the introduction article of this wiki) as well as the introduction article to the API in this wiki.
In order to understand our code you first need to understand the difference between GraphQL types and model types. Let's take the following two GraphQL types Article
and ArticleRevision
:
type Article implements AbstractUuid {
id: Int!
currentRevision: ArticleRevision
...
}
type ArticleRevision implements AbstractUuid {
id: Int!
title: String!
content: String!
...
}
We will refer to those types which directly correspond to types in our GraphQL schema as GraphQL types. They live in ~/types
and are automatically generated by the tool GraphQL code generator via the command yarn codegen
. So each time you change the GraphQL schema you need to run yarn codegen
as well so that ~/types
gets updated. Those GraphQL types can be directly translated into TypeScript types:
interface Article extends AbstractUuid {
id: number
currentRevision: ArticleRevision | null
...
}
interface ArticleRevision implements AbstractUuid {
id: number
title: string
content: string
...
}
In the Serlo software, GraphQL types are represented by so called model types or model objects. They hold all information necessary to dynamically get all properties of the GraphQL type they represent. They are located in ~/internals/graphql
. As a parameter you give the name of the GraphQL type, union or interface. For the GraphQL type Article
its model type is for example:
type Model<"Article"> = {
id: number
currentRevisionId: number | null
...
}
Sidenote: You see that instead of currentRevision
being an object of type ArticleRevision
it only holds the id of its currentRevision
(or null
in case there is no reviewed revisions yet). When currentRevision
is requested by a GraphQL query we can use this id
to retrieve the current revision dynamically. Thus we do not need to directly store the current revision inside the model. This has some advantages:
- Performance: When a model of an article is created we only need to retrieve the id of its current revision and therefore we do not need to query the whole revision object. This will end fewer database requests and thus a better performance.
- Caching: The model object of an article is smaller. When we cache it we have a smaller footprint in necessary space. Also the handling of updates is easier since we do not need to update the article model object when the revision model object changes.
Similarly Model<"ArticleRevision">
is the model type of ArticleRevision
and Model<"User">
is the model type of User
. The type function Model<"...">
also works for GraphQL unions / interfaces. In this case it will return the union of all model types whose GraphQL type are in the GraphQL union or implement the GraphQL interface. For example:
type Model<"AbstractUuid"> = Model<"Article"> | Model<"ArticleRevision"> | Model<"User"> ...
See https://graphql.org/learn/schema/#interfaces and https://graphql.org/learn/schema/#union-types for an introduction about GraphQL unions / interfaces and https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types for an introduction about union types in TypeScript.
Since model types are not the same as GraphQL types we need functions which can dynamically calculate the properties of a GraphQL type which are missing in the model. These functions are called resolver functions. In the above example we need a special function currentRevision()
since we have decided that the current revision of an article shall be calculated "on the fly" when a GraphQL request is made. Those functions live inside the special object resolvers
which holds all resolver functions of all GraphQL types:
const resolvers = {
Article: {
currentRevision(parent, args, context) {
...
}
}
}
The parent
is the model object described in the previous section. For example the object { id: 123, currentRevisionId: 456, ... }
might be passed as parent
to currentRevision
. args
are arguments which might be passed to the GraphQL request for this particular property (see https://graphql.org/learn/queries/#arguments ). Since currentRevision
has no arguments defined in our schema it will be an empty object {}
at runtime. context
is a dictionary with helper objects for calculating the property. For example context
will have a dataSources
object which which requests to the used services can be made (see the article data sources).
Since the property currentRevision
is defined by
type Article {
currentRevision: ArticleRevision
...
}
the return type of the resolver function currentRevision()
need to be the model type of ArticleRevision
or null
(Note that there is no exclamation mark !
after ArticleRevision
in the GraphQL schema and thus the property can be null
). An implementation of currentRevision()
is:
const resolvers = {
Article: {
async currentRevision(parent, _args, { dataSources }) {
if (parent.currentRevisionId === null) return null
return await dataSources.serlo.getUuid({ id: parent.currentRevisionId })
}
}
}
So in case currentRevsionId
is not null
we make a request to the database layer to resolve the uuid of the revision. We have programmed the database layer in a way that it already returns the model type for each uuid so that in this case the model type of an article revision is returned. If you want to know more, this wiki also contains an in-depth article about Resolvers and their type helpers.
As you have seen in the above example the function currentRevision()
uses a special function getUuid()
of the object dataSources.serlo
to resolve an article revision. Those objects are called data sources. They abstract requests to services which we use in resolver function to retrieve data. Lets recall our software architecture:
For each of the used services we have a data source. They all live inside the directory ~/model
. There is for example ~/model/serlo
for requests to our database layer and ~/model/google-spreadsheet-api
for accessing the Google Spreadsheet API. For more details about data sources see also this article
GraphQL types are the types of our GraphQL schema. Thus they refer to the type system the clients use to access our data and we encode with them the way "the outside world sees Serlo". Internally we use model types to represent GraphQL types. Any missing properties in the model types are calculated dynamically via resolver functions. Therefore we have
GraphQL types = model types + resolver functions
The above "equations" means that all properties of a GraphQL type are either already represented in the model type or they are calculated dynamically via resolver functions.
In order to retrieve data from other services we have data sources. A data source abstracts requests to the services and is given by the context variable to the resolver functions.
The next article in this series is this one about how a GraphQl request is resolved.