Full stack with GraphQL

Rachèl Heimbach

Full stack dev @ OpenValue

REST

  • Lots of endpoints

  • HTTP caching

  • Full resources

  • Versioning

  • One endpoint

  • Client caching

  • Specific fields

  • No versioning

Technical comp.

REST

  • Backend creates endpoints
  • Frontend consumes endpoints
  • Backend creates 1 schema
  • Frontend consumes schema

Process comp.

What does this mean?

  • Frontend should understand backend resource performance

  • Backend should understand frontend reqs

  • Shared responsibility performance

Webshop

github.com/rachnerd/webshop

Branch: codemotion-asssignment-1

Webshop

Server-side

const resolvers: GQLResolvers<ServerContext> = {
  Query: {
    items: (_,  __, { itemsService }) => {
      console.debug("Queried items");
      return itemsService.get();
    }
  },
  Item: {
    price: (item, _, { pricesService }) => {
      console.debug("Queried item price");
      return pricesService.getById(item.id);
    }
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: (): ServerContext => ({
    itemsService: new ItemsService(),
    pricesService: new PricesService()
  }),
});

server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});

N+1 Problem

N + 1 problem

const resolvers: GQLResolvers<ServerContext> = {
  Query: {
    items: (_,  __, { itemsService }) => {
      console.debug("Queried items");
      return itemsService.get();
    }
  },
  Item: {
    price: (item, _, { pricesService }) => {
      console.debug("Queried item price");
      return pricesService.getById(item.id);
    }
  }
};
{
  items {
    id
    title
    description
    image
    category
    price
  }
}
Queried items
Queried item price
Queried item price
Queried item price
...

20 items:

  • 1 items call
  • 20 price calls

N + 1 problem

export class PricesService {
  
  // ...
  
  async getById(id: number): Promise<number> {
    return fetch(`${PRICES_ENDPOINT}/${id}`)
      .then((res) => res.json());
  }

  // ...
}
const resolvers: GQLResolvers<ServerContext> = {
  Query: {
    items: (_,  __, { itemsService }) => {
      console.debug("Queried items");
      return itemsService.get();
    }
  },
  Item: {
    price: (item, _, { pricesService }) => {
      console.debug("Queried item price");
      return pricesService.getById(item.id);
    }
  }
};

Dataloader

Resolver

Resolver

Resolver

input 1

input 2

input 3

request

response

output 2

output 3

output 1

N + 1 solution

export class PricesService {
  private priceDataLoader = new DataLoader<number, number>(
    async (ids: number[]) =>
      ids.length === 1
        ? [
            await fetch(`${PRICES_ENDPOINT}/${ids[0]}`).then((res) =>
              res.json()
            ),
          ]
        : this.getByIds(ids)
  );

  async getById(id: number): Promise<number> {
    return this.priceDataLoader.load(id);
  }

  async getByIds(ids: number[]): Promise<number[]> {
    return fetch(`${PRICES_ENDPOINT}?ids=${ids.join(",")}`)
      .then((res) => res.json());
  }
}

DataLoader

20 items:

  • 1 items call
  • 1 prices calls

What's next?

Allow the client to optimize

Paged items

const resolvers: GQLResolvers<ServerContext> = {
  Query: {
    items: (_,  { params }, { itemsService }) => {
      console.debug("Queried items");
      return itemsService.get(params);
    }
  },
  // ...
};
export class ItemsService {
  // ...

  async get({ page, size }: GQLItemsParams): Promise<GQLPagedItems> {
    return fetch(
      `${BACKEND}/api/items?page=${page}&size=${size})}`
    ).then((res) => res.json());
  }

  // ...
}

DEMO

Recap

  • Query resolvers (item)
  • Type resolvers (Item:price)
  • N + 1 problem -> DataLoader
  • Query params (Paging)

Webshop

Client-side

  /**
   * Fetch remote items to display.
   */
  const itemsQuery = useQuery<Paged<RemoteItem>>("items", () =>
    getItemsRequest({ paging: PAGING })
  );

  /**
   * Fetch remote cart information.
   */
  const cartQuery = useQuery<RemoteCartItem[]>("cart", () =>
    getCartRequest()
  );

  /**
   * Keeping items as local state
   */
  const [items, setItems] = useState<ClientItem[] | undefined>(undefined);

  /**
   * Aggregation logic that populates items with `amountInCart`
   * RemoteItem -> ClientItem
   */
  useEffect(() => {
    const remoteItems = itemsQuery.data?.content;
    const cart = cartQuery.data;

    if (remoteItems && cart) {
      const normalizedCart = normalize(cart);
      const clientItems = remoteItems.map((item) => ({
        ...item,
        amountInCart: normalizedCart[item.id]?.quantity || 0,
      }));
      setItems(clientItems);
    }
  }, [itemsQuery.data, cartQuery.data]);
export default function Index() {
  const itemsQuery = useItemsQuery({
    variables: {
      size: 6,
      page: 0,
    },
  });

  // ...

  return (
    <HomePage 
      itemsState={{
        loading: fastItemsQuery.loading,
        data: itemsQuery.data?.items.content,
        error: fastItemsQuery.error,
      }} 
    />
  )
}
query Items($page: Int!, $size: Int!) {
  items(params: { page: $page, size: $size }) {
    content {
      id
      title
      description
      image
      category
      price
      amountInCart
    }
    page
    size
    totalResults
  }
}

DEMO

Performance fix

  • Backend optimization
  • UI Redesign
  • Query split
query FastItems($page: Int!, $size: Int!) {
  items(params: { page: $page, size: $size }) {
    content {
      id
      title
      description
      category
      image
    }
  }
}
query SlowItems($page: Int!, $size: Int!) {
  items(params: { page: $page, size: $size }) {
    content {
      id
      price
      amountInCart
    }
  }
}
export default function Index() {
  const [items, setItems] = useState<ClientItem[]>([]);

  const fastItemsQuery = useFastItemsQuery({
    variables: PAGING,
  });

  const slowItemsQuery = useSlowItemsQuery({
    variables: PAGING,
  });

  const fastItems = fastItemsQuery.data;
  const slowItems = slowItemsQuery.data;

  useEffect(() => {
    if (fastItems && slowItems) {
      const slowUpdates = normalize(slowItems.items.content);
      const combinedItems = fastItems.map((item) => ({
        ...item,
        ...(slowUpdates[item.id] || {}),
      }));
      setItems(combinedItems);
    }
  }, [fastItems, slowItems]);
  // ...
}

Stale data fix

  • Refetch data

 

 

 

  • Update local cache

 

 

 

await addToCartMutation({
  variables: { id, quantity },
  update: (cache) =>
    cache.writeFragment({
      id: `Item:${id}`,
      fragment: gql`
        fragment UpdatedItem on Item {
          amountInCart
        }
      `,
      data: {
        amountInCart: quantity,
      },
    }),
  });
await addToCartMutation({
  variables: { id, quantity },
  refetchQueries: ['SlowItems'],
});

Recap

  • Apollo state management
  • Split bad performing queries 
  • Design UI for partially loaded data
  • Update local cache after mutations

Questions?

Full stack with GraphQL

By rachnerd

Full stack with GraphQL

  • 174