Disclaimer: 2023-04-26 currently working on this project, so it is not perfect yet
This project serves as a demonstration of the power of Go's concurrency and the capabilities of building high-performance distributed systems by combining Go's native concurrency support with technologies like Kafka and Redis.
The repository encompasses the entire microservice architecture, with infrastructure residing in the /infrastructure
directory and microservices residing in /micro-services
.
The microservice architecture consists of several services, including the Rest API, Cart service, Inventory service, Prediction service (to be added), and Order service (to be added). The Rest API acts as the bridge between the outside world and the microservices and provides easy endpoints for frontend developers to integrate a shopping-cart feature into their online shop. The Cart service manages the shopping carts of users, while the Inventory service ensures that a user cannot add an out-of-stock product to their cart. The Prediction service, which is yet to be added, will consume add-product events, do a simple prediction on similar products that are bought together, and send back the result to the Rest API. The Order service, which is also yet to be added, will handle the orders placed by the users.
The infrastructure of this microservice architecture uses Postgres to store information about the inventory, as it is naturally tabular in format and requires a SQL database. Kafka serves as the backbone and heart of the system, enabling asynchronous communication between the components. Redis was added to the mix primarily to provide distributed mutex locks, which prevent race conditions on a distributed level, ensuring a scalable and reliable distributed system.
Overall, this microservice architecture is designed to be scalable, distributed, and reliable, thanks to the use of advanced technologies like Kafka and Redis, and it can handle a high volume of requests while maintaining high performance.
Below is a description of each service that is currently avaiable.
The Rest API serves as the bridge between the outside world and the micro-service architecture. While it could be classified as one of the micro-services itself, its primary purpose is to provide easy endpoints for frontend developers to integrate a shopping-cart feature into their online shop.
The Rest API supports basic CRUD operations, including adding and removing products, getting the current cart, and placing an order. While the real magic happens in the cart-service and the inventory-service, there are some cool features here too.
When adding a product to the cart, the Rest API addresses the potential problems that arise in online shopping carts, such as ensuring that the product is in stock and avoiding race conditions when two customers attempt to add the same product simultaneously.
To address these issues, the normal HTTP call is upgraded to a websocket to allow for asynchronous communication while still providing responses. Here's how it works:
- The client makes a GET-request to
/cart/update-product
, sending information such asuserID
,productCode
, andquantity
. - The Rest API converts this information into a
cart-request
and sends it via achannel
to thekafkaCommunicator
, which publishes requests and listens on requests-responses. - The connection is upgraded to a websocket, which starts listening on the
response-channel
for the response from the request sent in step 2. - When the response arrives in the
response-channel
, it is published to the websocket, allowing the client to know whether the product was added successfully or not.
In addition to the /cart
endpoint, the Rest API also has two other endpoints:
/test
- functions to test the health of the Rest API, such as availability./migration
- functions to initialize the database by loading theinventory
and creating necessary views and tables.
The cart-service is responsible for managing the shopping carts of users, both keeping the state of the cart and altering it, such as adding and removing products.
To avoid false promises, such as adding a product that was just snatched by another user, the cart-service uses the concept of pending-requests
. A pending-request
is a request for either adding or removing a product from the cart that has not yet been processed by the inventory-service
.
Adding a product to the cart follows this flow:
- The cart-service receives a
cart-request
from Kafka, published by therest-api
. The Kafka message is published in a channel that thecart-service
listens on. - The cart-service handles this request by doing the following two things: adding a
pending-request
to the cart and publishing aninventory-request
to the Kafka-bridge, which is later handled by theinventory-service
. - When a response with the same
requestID
as the one published arrives from theinventory-service
, via Kafka, if successful, the cart-service will update the cart and publish acart-request-response
for therest-api
.
When removing a product, the request will be applied to the cart directly and return a response to the rest-api. When the inventory-request is then handled, no additional response will be published. This is because removing a product should not be dependent on the inventory-service.
Since the cart-service works asynchronously, each cart
is stored in a cartHandle
that has a mutex
lock. This way, we can avoid any races. Every time a change is being made to the cart-object, the mutex-lock is checked out.
The inventory-service
plays a crucial role in ensuring that a user cannot add an out-of-stock product to their cart. When the cart-service
receives a request to add a product to a cart, it publishes an inventory-request
to Kafka. The inventory-service
listens for these requests and checks the product's availability before placing a hold on it, if possible. Once the inventory status has been determined, the inventory-service
sends the result back to the cart-service
via Kafka.
The inventory-service
relies on a database that contains two primary tables and one primary view:
inventory - table
: This table stores the current inventory and has columns such as product_code
and quantity
.
hold - table
: This table stores all current holds and has columns such as product_code
, user_id
, created_at
, and hold_quantity
. When a new hold is created, the inventory-service
adds a row to this table.
available_products - view
: This view shows the current available products, which is calculated as the inventory quantity minus the hold quantity.
To prevent negative inventory counts caused by concurrent writes to the hold
table, the inventory-service
employs distributed locks with Redis. If the requested hold quantity is greater than five or the available inventory count after the hold is less than five, the inventory-service
requires the workers to check out the Redis lock for that product code. This approach enables concurrent writes for high-stock products while avoiding negative inventory counts for low-stock products.
This services will consume add-product events, do a simple prediction on similar products that are bought togehter and send back the result to the rest-api.
Below is a description of the infrastructure used for this microservice architecture.
Kafka serves as the backbone and heart of this microservice architecture, which is logical given that the essence of a microservice architecture is to decouple all components with the aid of an event bus.
Although there are numerous other options, I have always been in contact with Kafka during my tenure as a Data Engineer and Data Ops due to its proficiency in handling big data. Hence, I opted to use Kafka. In the future, I intend to integrate ksqlDB to enable real-time analytics, but that is a task for another day.
I am not sure if I need to justify this selection, but I used Postgres to store information about the inventory because it is naturally tabular in format, necessitating a SQL database. The decision to use Postgres over other SQL databases was simply because I am biased and prefer Postgres.
Redis was added to the mix primarily due to the distributed mutex locks it provides out of the box, rather than as a cache solution.
At the project's outset, I relied solely on local mutexes from the sync package in Go to prevent scenarios where different workers attempted to reserve the same product at the same time, resulting in a negative inventory count. This solution worked as long as only one instance of the inventory service was running. However, it did not function if I wanted to create a scalable and distributed system, which I did.
Therefore, by integrating Redis into the infrastructure, the system can now avoid race conditions on a distributed level.