Creating Your Own E-Commerce Keystone.js-Based System — Build a Cart
Next steps in our journey to create our own e-commerce system
Photo by Kenny Eliason on Unsplash
Intro
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.
Cart Flow
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 Cart schema:
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.
Cart Items List
The main purpose of this list is to store relationships between Cart and 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 cart-product.schema.ts:
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 Product to CartProduct. Additionally, there's no longer a need to hide the possibility of creating this entity from Admin UI.
products: relationship({
ref: 'CartProduct',
many: true,
}),
OK, now we can update our flows:
- Adding to cart:
- Validate stock
- Create CartProduct entities
- Update Cart model
- Remove product from cart:
- Remove CartProduct entity
- Update Cart
- Change quantity in cart:
- Validate stock
- Update CartProduct entity
- Update Cart
A Word or Two on Framework Abstractions and Their Limitations
But why all that trouble? Let’s take a look at the current ER diagram of our database:
We have our Cart, CartProduct, and 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 efficiently. But in some cases, we have to accept some issues.
Hooks and Validation Flow
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.
There’s Hooks API, which does exactly that on the whole schema or particular fields in it. There are five of them:
- resolveInput allows us to modify input data before validation on create or update operation.
- validateInput and validateDelete gives us the possibility to return additional validation errors in the create/update and delete operations, respectively.
- beforeOperation handles side effects before database operation
- afterOperation does 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 Cart schema:
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 validateInput hook:
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 Cart, there’s 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.
Summary
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!
Top comments (0)