Optimizing API Endpoint (Real World Case Study)
Published: 2023-08-10 16:03:17

## Situation

I had to work on a task where we wanted to show payment information of orders to users which meant as the backend engineer, I had to add payment information to the data being sent to the client.
The payment data is on another service so had to make a request to that service so I can add it to the order information. The task itself was pretty easy, however prior to doing the task, I realised the endpoint was already taking about `3s` to return a response. `3s` may not seem like a lot but in the age of TikTok, It was too long. The endpoint was doing two things under the hood when the client makes a request.

1. Make a request to orders service for orders.
2. Make a request to product service product images to update order items with current product images.

I didn't think these processes warranted the response time especially when the response was paginated so investigated the implementation details.

## Problem
### Multiple Calls

When we make a request to get orders from our order service we make another request to the products service to get an updated image of the order items in each order. Each order item is a product and we needed to get images of those products from another service to include it in the order item when returning orders data to the client. Our proxy service which from here will be regarded as bff (backend for frontend) is the one making these calls. The implementation was in this manner;
**In bff**
1. We loop through orders 
2. Then loop through the order items in each order
3. Get the product id of each order item, put them in a list then make a request to the product service for the current image of the order items.

> _All Code snippets have been modified to represent the logic of the implementation. This simplifies the code so the reader can easily understand without having to worry about other unnecessary details._
>  

```python
def get_images_for_orders(orders):
	for order in orders:
		product_ids = []
		order_items = order["order_items"]
		for order_item in order_items:
			product_id = order_item["product_id"]
			product_ids.append(product_id)

		response = make_request_for_product_images(product_ids)
		images = response["results"]
		result = add_images_to_orders(images, order_items)

	return result
```

A request was being made to the products service to get product images on each iteration of order thus leading a network for each order. This was done this way because the images were needed in certain order to be able to easily match each order item to image. So first product image in the list had to belong to the first order item.

**In products service**
1. Receive the request for images, get the product uuids
2. Loop through the product ids and query the database for product image on each iteration.
```python
def get_product_images(product_ids):
	product_images = []
	for prod_id in product_ids:
		## The first product image is being retrieved because a product can
		## have multiple images
		product_image = ProductImage.objects.filter(product_id=prod_id).first()
		product_images.append(product_image)
	return product_images
``` 

Doing this meant, we make a db call on every single product id. 
This meant if our request for orders returns 20 orders per page and each order had 2 order items. We are going to make 20 network calls in the bff and 40 database queries in the products service.

## Solution

To increase the response time, there were two solutions I had to implement.
1. Make one network call in the bff.
2. Make one db call in the products service.

- The first solution was implemented by putting all the product ids of order items into one list before making the network call.
- The second solution was implemented by modifying the db call so we can get distinct product images.
- Implementing the first two posed another challenge. We needed to find a way to match each image to its respective order item. That was resolved using a dictionary (hash table).

### Refactoring

1. Make one network call
**In bff**
```python
def get_images_for_orders(orders):
	order_items_dict = {}
	for order in orders:
		order_items = orders["order_items"]
		order_items_dict.update(
			{order_item["product_id"]: order_item for order_item in order_items}
		)
	product_ids = list(order_items_dict.keys())

	response = make_request_for_product_images(product_ids)
	images = response["results"]
	result = add_images_to_orders(images, order_items_dict)

	return result
```

Putting `order_items` in a dictionary `order_items_dict` with `product_id` as key and the `order_item` as value, meant we don't have to worry about the order the product images come in, we can just match them to their product ids in `order_items_dict` thus resolving our challenge of matching the images to the order.
We can then get all the keys which will now be the list of the `product_ids` and use them for our request.

2. Make one database call
**In products service**
```python
def get_product_images(product_ids):
	product_images = (
            ProductImage.objects.filter(
                product_id__in=product_ids
            )
            .order_by("product_id")
            .distinct("product_id")
        )
	return product_images

```

This query ensures we only get one product image per `product_id`.

## Results

These little changes reduced the response time of our calls from `3s` to `1s`. Also, these changes ensures only one network call and only one db call is made no matter the number of orders, thus changing our time complexity from `O(n)` to `O(1)`. Last but not least, this endpoint is widely used by a number of teams in the company and getting orders went from being noticeably delayed to almost instant thus saving a lot of time.