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
- 169