Graphql Schema Design

A good schema is half the API

https://slides.com/peco/graphql-schema-design

The slides

Demo Project

https://github.com/mjrio/mjr-full-stack-graphql

Graphql

Architecture

Graphql query

Why we need design

  • Build API's that are easier to evolve.

  • Build API's that are easier to reason about

Graphql should not be a interface to your database

Graphql should not match your existing REST resources

GraphQL should not be a 1:1 mapping of your UI

Graphql lets us model an interface to our domain.

  • It describes the key concepts of your product,

  • the relations between these concepts

  • and the core actions your system supports

Northwind Db Model

Northwind REST API

GET /api/orders
GET /api/orders/:id
GET /api/orders/:id/details

GET /api/products
GET /api/customers
GET /api/categories
GET /api/suppliers

POST /api/orders
POST /api/products
...

Thinking in Graphs

With GraphQL, you model your business domain interface as a graph

Northwind Business Model

Schema or Code First

Which approach to take

Schema First (JS)

const typeDefs = `
  type Query {
    book(id: String): Book
  }

  type Book {
    name: String
    description: String
  }
`;

const resolvers = {
  Query: {
    book: (root, args) => fetchBookById(args.id),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
server.listen();

Schema First (C#)

// Book.cs
public class Book {
    public string Title { get; set; }
    public string Author { get; set; }
}

// Query.cs
public class Query {
    public Task<Book> GetBookAsync(string id) {
        // Omitted code for brevity
    }
}

// Startup.cs
public class Startup {
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddRouting()
            .AddGraphQLServer()
            .AddDocumentFromString(@"
                type Query {
                  book(id: String): Book
                }
                type Book {
                  title: String
                  author: String
                }
            ")
            .BindComplexType<Query>();
    }
}

Code First (C#)

// Query.cs
public class Query
{
    public Task<Book> GetBookAsync(string id) {
        // Omitted code for brevity
    }
}

// QueryType.cs
public class QueryType : ObjectType<Query>
{
    protected override void Configure(IObjectTypeDescriptor<Query> descriptor) {
        descriptor.Field(f => f.GetBook(default)).Type<BookType>();
    }
}

// Book.cs
public class Book {
    public string Title { get; set; }
    public string Author { get; set; }
}

// BookType.cs
public class BookType : ObjectType<Book>
{
    protected override void Configure(IObjectTypeDescriptor<Query> descriptor)
    {
        descriptor.Field(f => f.Title).Type<StringType>();
        descriptor.Field(f => f.Author).Type<StringType>();
    }
}

// Startup.cs
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddRouting()
            .AddGraphQLServer()
            .AddQueryType<QueryType>();
    }
    // Omitted code for brevity
}

Code First (C#)

// QueryType.cs
public class QueryType : ObjectType<Query>
{
    protected override void Configure(IObjectTypeDescriptor<Query> descriptor) {
        descriptor.Field(f => f.GetBook(default)).Type<BookType>();
    }
}

// BookType.cs
public class BookType : ObjectType<Book>
{
    protected override void Configure(IObjectTypeDescriptor<Query> descriptor)
    {
        descriptor.Field(f => f.Title).Type<StringType>();
        descriptor.Field(f => f.Author).Type<StringType>();
    }
}
type Query {
  book(id: String): Book
}
type Book {
  title: String
  author: String
}

Schema First

  • A design language between teams
  • Mostly results in a
    better-designed API
  • Teams can work simultaneously

Code First

  • Code is the single source of truth
  • The API design will be influenced by the implementation.
  • Backward-incompatible changes can slip in more easily

👍

Lets start a design

Northwind GraphQL API

Step 1 - Types

type Product {
  id: ID!
  name: String
  unitPrice: Float
}

type Category {
  id: ID!
  name: String
  description: String
}

type Customer {
  id: ID!
  companyName: String
}

type Supplier {
  id: ID!
  companyName: String
}

type Order {
  id: ID!
  orderDate: String
}

Scalar types

Type Desc
Int A signed 32-bit integer
Float A signed double precision floating point value
String A signed double precision floating point value
Boolean Represents true/false
ID Unique identifier

Step 2 - More Types

type Address {
  street
  city
  region
  postalCode
  country
}

type Customer {
  id: ID!
  companyName: String
  address: Address
}

type Supplier {
  id: ID!
  companyName: String
  address: Address
}

type Order {
  id: ID!
  orderDate: String
  shippingSddress: Address
}

Step 3 - Queries

type Query {
  category(id: ID!): Category
  categories: [Category!]!
  
  product(id: ID!): Product
  products: [Product!]!

  customer(id: ID!): Customer
  customers: [Customers!]!
}

Notice the arguments and required fields

Step 4 - Improve Queries

type Query {
  category(id: ID!): Category
  categories: [Category!]!
  
  product(id: ID!): Product
  products(limit: Int, offset: Int, orderBy: String): [Product!]!

  customer(id: ID!): Customer
  customers(limit: Int = 20, offset: Int = 0): [Customers!]!
}

Add paging & sorting when needed

Step 5 - Relations

type Product {
  id: ID!
  name: String
  unitPrice: Float
  category: Category
}

type Order {
  id: ID!
  orderDate: String
  customer: Customer
  products: [Product]
}

Model your relations

Step 6 - More Relations

type OrderDetail {
  product: Product
  unitPrice: Float
  quantity: Int
  discount: Float
}

type Order {
  id: ID!
  orderDate: String
  customer: Customer
  details: [OrderDetail]
}

Create sub types when needed

Step 7 - Mutations

input CreateOrderInput {
  customerID: ID!
  productID: ID!
  unitPrice: Float!
  quantity: Int!
  discount: Float!
  shippingAddress: Address
}

type Mutation {
  addCategory(name: String!, description: String!): Category
  addproduct(name: String!, unitPrice: Float!): Product
  createOrder(input: CreateOrderInput!): Order
}

Notice the input type

Step 8 - Documentation

# Mutations

input CreateOrderInput {
  "the price or the product on the moment of the order"
  unitPrice: Float!
  " the given discount on the product"
  discount: Float!
}

type Mutation {
  """
  Create the order, decrement the stock  
  and start the shipping process
  """
  createOrder(input: CreateOrderInput!): Order
}

Step 9-10 - Evolve

type Product {
  ...
  tags: [String]
  tag: String @deprecated(reason: "Use `tags`.")
  ...
}

By using the @deprecated directive we can document our changes.

Performance

Can be a deal breaker

An example

query {
  products {
    id
    unitPrice
    category {
      id
      name
      description
    }
  }
}

GraphQL will need to execute N+1 queries against the database. 🥵

The N+1 problem is GraphQL’s archenemy, something to avoid at all costs

 

The DataLoader to the rescue 😀

“DataLoader is a generic utility to be used as part of your application’s data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching.”

Schema design patterns

Use the power of GraphQL

Use consistent naming

  • Fields should be named in camelCase
  • Types & Enum should be named in PascalCase.

  • Use all capital letters for ENUM values, as they are basically constants

  • Type should have id field of type ID!

enum Language {
  NL,
  FR
}

type BlogPost {
  id: ID!
  name: String
}

type Query {
  blogPosts: [BlogPost]
}

Avoid confusion

type Query {
  # bad
  product(id: ID, slug: String) Product
  
  # good
  productById(id: ID!) Product
  productBySlug(slug: String!) Product
}

type Mutation {
  # bad
  updateCart(cart: Cart) Cart
  
  # good
  addProductsToCart(cartId: ID, productIds: [ID!]!) Cart
  clearCart(cartId: ID) Cart
}

Unique ID's

  • Remember the client. His caching is based on the ID and type
type Code {
  id: ID!
  name: String!
}

type Customer {
  id: ID!
  name: string
  languageCode: Code
  countryCode: Code
}

{ 
  "id": "ANUI23232",
  "name": "MyCorp",
  "languageCode": {
    id: "1",
    name: "Dutch"
  },
  "countryCode": {
    "id": "1",
    "name": "Belgium"
  }
}

Bad

Extend your Scalar types

Scalar Type Example
Date (ISO 8601) 2020-02-01
TimeStamp (ISO 8601) 1994-11-05T13:15:30Z
Email name@no-reply.com
CreditCard
(Luhn Algorithm)
4111 1111 1111 1111
PhoneNumber
(E.164 format)
+44207183875044
VAT number BE1234567890
IBAN BE68 5390 0754 7034

Extend your Scalar types

type Order {
  orderDate: Date
  shippingTime: TimeStamp
}

type Customer {
  contactEmail: URL
}

input CreateOrderInput {
  orderDate: Date!
  productID: String
}

type Mutation {
  createOrder(input: CreateOrderInput): Order
}
  • Improves type definition
  • Improves field correctness
  • Adds input validation

Advantages

Pagination (offset based)

type Query {
  products(limit: Int, offset: Int, orderBy: String): [Product!]!
}
query {
  products(limit: 10, offset: 0, orderBy: "iPhone-") {
    id
    name
  }
}

Problem of offset based pagination

Pagination (cursor based)

type Query {
  products(first: Int, after: String): ProductsConnection!
}

type ProductsConnection {
  totalCount: Int!
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String!
  endCursor: String!
}

type ProductEdge {
  node: Product!
  cursor: String!
}

Mutation (not so good)

type Mutation {
  addProduct(name: String!, unitPrice: Float!, tag: String): Product
}
mutation AddProductMutation($name: String!, $unitPrice: Float!, $tag: String ) {
  addBlogPost(name: $name, unitPrice: $unitPrice, tag: $tag ) { 
    id
    name
  }
}
  • Lengthy arguments to mutations
  • Result is not extendable

 Mutation (improved)

input AddProductInput {
  name: String!
  unitPrice: Float!
  tag: String
}

type AddProductPayload {
  product: Product
  status: String
}

type Mutation {
  addProduct(input: AddProductInput): AddProductPayload!
}

Schema

mutation AddProductMutation($input: AddProductInput ) {
  addProduct(input: $input ) { 
    product {
      id
      name
    }
    status
  }
}

 Mutation (improved)

Mutation

REST API

  • Token based
  • Token verified on incoming request
  • Global Security
    • ​401 HTTP Error 
  • Authorization
    • 403 HTTP Error

 

GRAPHQL API

  • Token based
  • Token verified on incoming request
  • Global Security
    • 401 HTTP Error
  • Authorization
    • ​Defined by schema
    • GraphQL error

Authentication / Authorization

Authorization

type Product {
  name: String
  price: Float
  itemsInStock: Int @isAuthenticated
}

type Query {
  products: [Prod!]!
}

type Mutation {
  # add a new product
  addProd(input: ProdInput): Product @hasRole(roles: ["admin"])
}

by using GraphQL Directives

Authorization

type User {
  name: String
  email: String
  roles: [String]
}

type Query {
  viewer User! @isAuthenticated
  
  # or
  me User! @isAuthenticated
}

Best to provide query to retrieve current user

Error Handling - validation

query {
  products {
    unknown
  }
}
{
  "errors": {
     "message": "Cannot query field 'unknown' on type 'Product'",
     "locations": [
        { "line": 4, "column": 5}
     ] 
  }
}

Result

Error Handling - resolver

{
  "data": {
    "products": null,
    "status": "OK"
  },
  "errors": {
     "message": "Cannot read property 'name' of undefined",
     "locations": [{ "line": 4, "column": 5}],
     "path": ["products"]
  }
}
query {
  products {
    ...
  }
  status
}

Result

Error Handling - Business Logic

{
  "data": {
    "login": null
  },
  "errors": [{ 
    "path": [ "login" ],
    "locations": [ { "line": 2, "column": 3 } ],
    "extensions": {
      "message": "Account is suspended",
      "type": 2
    }
  }]
}
mutation login($input: LoginInput) {
  login(input: $input) {
    ...
  }
}

Result

Error Handling - improved

type LoginResult {
  error: String
  errorCode: String
  accessToken: String
  email: String
}

type Mutation {
  login(input: LoginInput): LoginResult
}
  • You need to query the error
  • What if you need specific error props

Error Handling - more improved

type SuspendedError {
  reason: String
}

type RemovedError {
  removalDate: DateTime
}

union LoginResult = SuspendedError | RemovedError | User; 

type Mutation {
  login(input: LoginInput): LoginResult
}

Error Handling - improved

mutation($input: LoginInput) {
  login(input: $input) {
    __typename
    ...on User {
      id
      name
    }
    ...on RemovedError {
      removalDate
    }
    ...on SuspendedError {
      reason
    }
  }
}

Node Interface

type Query {
  user(id: ID!) User
  product(id: ID!) Product
  order(id: ID!) Order
  category(id: ID!) Category
  ...
}

Typical queries to get single object

Node Interface

type Query {
  node(id: ID!) Node
}

Can we build a query to retrieve any type?

Node Interface

interface Node {
  id: ID!
}

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

type Product extends Node {
  id: ID!
  title: String
}

type Query {
  node(id: ID!) Node
}
  • Convention to fetch an object
  • Support client side caching
  • Id must be globally unique:
    Global ID

Requirement

Advantage

Node Interface

query {
  node(id: "dXNlci4xMjM0TY3ODkw") {
    id {
    ... on User {
       name
    }
  }
}

query

Resources

Now start building some kickass GraphQL servers

Or see you in an upcoming GraphQL training

Made with Slides.com