Next steps in our journey to create our own e-commerce system
Some time ago I had a wild idea to use Kesytone.js to build an e-commerce system. This journey started a couple of weeks ago, and until now we’ve talked about system requirements, environment setup and base models, and access control. In this article, let’s focus on main cart functionalities. Also, the finished code for this article is available on my GitHub.
In previous parts of this series, when we set up the basic schemas, we decided that each user would have only one cart, and it would contain all added products until the user created an order from that cart. Based on that, users can perform three kinds of operations on their cart. First, there’s the possibility to add an item to the cart, then remove it and change its quantity.
Also, there’s one major issue to consider, and it’s more of a business than a technical problem. Should we allow users to add more products to the cart than is currently available? I mean, in the case where the product has stock, for example, four items are available, but the user tries to add to cart five, what should happen in that case?
Of course, that issue should be secured in UI, but in some cases, it can happen anyway. Using SSR and Next.js has some drawbacks, and in the most basic case, the quantity of available products is only checked on page rendering. This may lead to cases when product availability may change in the time between render and the moment of adding a product to the cart.
There are two main solutions: first, block adding to the cart in that case or move this validation step forward and block creating orders with products out of stock. Despite our decision, this step is necessary from a security point of view.
I believe there’s a third solution to this problem — something in between the previously mentioned two.
Stock schema includes information about the next delivery, so if there aren’t enough items in stock, but together it’s enough then the user can add it to the cart. But the order will be delayed because of that.
On the other hand, if there’s no next delivery information, it will be blocked. This solution should ensure better user retention and what’s more important is more interesting to implement.
With that out of the way, we can focus on each of these three operations. First, add a product to the cart. There are basically two steps in that. Validate stock and update products in the cart. The same applies when updating the quantity. Removing products is just an update to the cart model, right? Not exactly. Let’s take a look on our
There is related to
Product list, but there’s no way to store information about the quantity of added products. So, we have to create an intermediate list (called a pivot table in SQL nomenclature) to handle the many-to-many relationship and store the quantity information.
The main purpose of this list is to store relationships between
Product entities and quantity information. Basically, it should be only requested as a relation from
Cart. There’s no point in requesting it directly. Let’s create
But wait, there’s only two fields? It’s simple, this list doesn’t need to know anything about
Cart or what these products belong to. But on the other hand,
Cart model needs this information, so we have to update this list and change the relation from
CartProduct. Additionally, there’s no longer a need to hide the possibility of creating this entity from Admin UI.
OK, now we can update our flows:
- Adding to cart:
1. Validate stock
- Remove product from cart:
- Change quantity in cart:
1. Validate stock
But why all that trouble? Let’s take a look at the current ER diagram of our database:
We have our
Product tables, but also there’s
_Cart_products table. We didn’t create the last one, right? Undelaying Prisma did that for us. That’s why it’s good to have a basic understanding of the tools we use.
Prisma has two ways of creating many-to-many relations (more information’s available in the docs ), explicit or implicit. In the first one, we are responsible for creating pivot tables and relationships on other tables in our
schema.prisma file. In the second one, we skip the pivot table and ORM creates it for us.
But in our case, we don’t have direct control over the
schema.prisma file; Keystone takes care of that and uses the implicit method. In most cases, it’s perfectly fine, but sometimes it may have some drawbacks, like this unnecessary table here.
Frameworks usually hide many implementation details under the thick abstraction layer, which is a good thing in most cases. It allows developers to focus on
business logic and work faster and more efficient. But in some cases, we have to accept some issues.
To perform all the steps involved in each cart operation we need a tool that allows us to perform some side effects, including additional validation while updating
Cart schema. Fortunately, Keystone has the perfect tool for that.
Hooks API, which does exactly that on the whole schema or particular fields in it. There are five of them:
resolveInputallows us to modify input data before validation on create or update operation.
validateDeletegives us the possibility to return additional validation errors in the create/update and delete operations, respectively.
beforeOperationhandles side effects before database operation
afterOperationdoes the same but after operation.
Read more about hooks in the docs.
OK, let’s get back to our system. The entire flow is simpler than it looks; We only need to use two hooks (the third is a bonus). First, let’s assume every
updateCart mutation has to have all products currently in the cart (previously added too). That way, when we submit a list of products, cart content is set to this list. When there’s an empty list, the cart content is cleared, and when there’s no product list, the cart content is not changed. So, for example, a mutation should look like this:
In order to handle that, we have to remove all
CartProduct entities and add a new one on each update. To do that, we need to use the
beforeOperation hook in
It’s quite simple — when there are products in the update mutation, then we query and remove all currently added products. After that, the current operation adds back all appropriate products with new/updated stocks. Also, when the data’s resolved and there’s an empty list of products, the cart content will be cleared.
OK, that’s the part about updating cart content, but what about stock validation. Shouldn’t it have happened before that? Yes, but it should happen in
CartProduct schema, not directly in the cart. We are going to add the
Here, it checks stock on each product and compares the requested amount with the combined stock and amount in the next delivery. If it’s not enough, we call the
addValidationError function to create a validation error. This method is almost perfect. There’s only one issue:
CartProduct Entities are created before the cart is updated, and when there’s a validation error, the
Cart entity won’t be updated.
But some rows in the first schema may have already been created, and it may leave orphan entries in
CartProduct table. It’s a perfect example of a case when the transaction should be used, but for now, there’s no such option in Keystone. According to this issue, it may change in the near future.
What about the last bonus hook? In
sum field containing information about the value of the entire cart, and we need a way to calculate it. The
resolveInput hook works the best:
It takes all products associated with this cart and sums up their amounts and prices. And after that, the data to save into the database is updated.
Now, we’ve finished the cart part of our e-commerce system. To be honest, this part of the application was harder to develop than I’d anticipated at first. But also, the implementation was not so difficult. Most of the work was thinking about the best way to solve that problem, not the problem itself.
For various reasons, it took me longer than I’d planned, and I hope you liked it. If you have any questions or comments, feel free to ask them.
A side project has one nasty characteristic: At first, they are exciting and interesting, but after some work, we don’t feel that way any longer. And I believe that’s why writing this part took me so long.
Don’t get me wrong, I’m still planning to finish this series and build this system, but in order to not lose the fun in it — and to prevent it from becoming a chore in the next article — I’ll take a break and write about something else.
See you there!