Building a Backend for My Old Furniture Store
A few years ago, I started a side project: a storefront that lists furniture for prospective customers. It was my first time understanding how a CMS works under the hood, so I decided to learn Sanity with it. I remember trying to understand GROQ queries and how all of it worked. I got there, and I built the storefront. Then years went by. Recently I decided to understand how the backend part of applications works. This was another friction moment. As a frontend engineer, I was used to consuming backend APIs, but I didn't really understand how they worked. So I went back to the furniture store and built a backend for it with NestJS and Postgres. Lo and behold, I found my way around. This post walks through what I built, flow by flow: auth, cart, orders, and payments. The mental model that helped One mental model made everything click: build the logic from services up to controllers. Services hold the core logic: creating a user, adding to a cart, placing an order. Controllers set up the routes for those services. These are what I always saw as endpoints from the frontend side. So when my frontend calls POST /auth/register, a controller receives the request and hands it to a service, which does the actual work. For years I only saw the endpoint. Now I know what sits behind it. The new terms Around services and controllers, NestJS has a few more pieces I had to learn. Modules group everything for one feature: the auth module holds auth's controller, service, and guards. Imports bring in other modules a module depends on. My auth module imports the users module and the JWT config module, because auth needs both. Exports make parts of a module available to other modules. I export the auth guards so other modules, like cart and orders, can protect their routes. Providers are the classes NestJS creates and manages for you, like services. Constructors are where a class asks for the providers it needs. NestJS sees what a service asks for in its constructor and passes it in. This is called dependency injection. Guards run before a request reaches the controller and decide whether it's allowed through. My auth guard blocks any request without a valid token. Auth: creating an account and logging in The core logic lives in the auth folder. Registration. I collect basic information about the user. The password is never stored as the user typed it. It's hashed, which is one-way: there's no key that turns a hash back into the password. Even if someone got access to the database, they couldn't read the passwords. The way it clicked for me: hashing is like a blender. You can't un-blend the result, but the same ingredients always produce the same mush. Login. Nothing gets decrypted. The backend hashes the password the user just typed, the same way it was hashed at registration, and compares the result to the stored hash. If they match, the password is correct. Tokens. After login, the user gets an access token. It contains the user's ID and is signed with the server's secret key, so the backend can tell that it issued the token and nobody changed it. Access tokens are short-lived, so I also use refresh tokens. These are long-lived and let users stay logged in without signing in again every time the access token expires. Cart The cart module is where a user goes through the store listing and saves items. Users can add available items to their cart, edit a cart item, and delete it. There's no product table in my database. Products still live in Sanity, so the backend fetches them from Sanity before building a cart. Postgres holds the cart, orders, and inventory; Sanity holds the catalog. When adding to the cart: if the item is already there, update it. If it isn't, create it. Prices also come from Sanity on the backend. Before an order is created, the backend computes the cart items and their total. The frontend never sends a price, so nobody can change it before paying. Orders and reservations The goal here: no two customers can pay for the same item, even under high traffic. When a customer places an order, the backend reserves the items in the database. The reservation is kept for 15 minutes. If the user doesn't complete payment in that time, the order expires, a background job releases the reservation, and the items become available again. The tricky part is the reservation itself. If I checked stock first and reserved it after, there'd be a gap where two users both see "1 left" and both reserve it. So I do both in one statement: const reservedRows = await tx.$executeRaw` UPDATE "Inventory" SET reserved = reserved + ${item.quantity} WHERE "productId" = ${item.productId} AND quantity - reserved - sold >= ${item.quantity} `; if (reservedRows === 0) { throw new BadRequestException(`${item.productName} is out of stock`); } The UPDATE only succeeds if enough stock remains. When two requests hit the same row at the same moment, Postgres makes the second one wait. It then re-checks the condition against the updated row. If the first request took the last item, zero rows are updated and the second user gets "out of stock." Available stock is quantity - reserved - sold. Reserving doesn't reduce quantity; it increases reserved. All of this runs inside a database transaction, along with creating the order and its order items. Either everything succeeds or nothing does. If a cart has three items and the third is out of stock, the reservations for the first two are rolled back too. The order also stores its total and each item's price at the moment it's created. If a price changes in Sanity later, it doesn't affect an order that's already being paid. Payments with Paystack Once an order is created, the user pays through Paystack's widget. Paystack is in test mode for this project. Starting a payment. The request first goes through my auth guard. The backend then calls Paystack with my Paystack secret key as a Bearer token in the authorization header. That key only ever lives on the backend. One gotcha: Paystack takes amounts in kobo, not naira, so the total has to be multiplied by 100. The webhook. This was the flip for me as a frontend engineer. Normally I call the API. With a webhook, Paystack calls my backend to tell me the payment's status. The webhook doesn't use my auth guard, because Paystack can't log in as a user. Instead, Paystack signs each webhook with my secret key and sends the signature in the x-paystack-signature header. My backend recomputes the signature and compares. If it doesn't match, the request is rejected, so nobody can fake a successful payment. Paystack sends back the transaction reference, which is the order ID I sent when initializing the payment. What happens next depends on the order's status: Pending: process the payment. Already paid, processing, shipped, or delivered: treat it as a duplicate webhook and ignore it. Paystack can send the same webhook more than once, and this makes sure an order is never processed twice. This is called idempotency. Cancelled, expired, failed, or refunded, or the amount doesn't match the order total: log the payment, but don't touch the inventory, because the customer hasn't received value for this transaction. If a customer actually paid for an expired order, their money was taken, so the payment is flagged for a refund. For now, refunds are done manually. Updating the order. For a valid payment, all the updates run in one database transaction, so either everything succeeds or nothing does. The order status changes to paid, the reservation is released, the sold count goes up, the cart items are deleted, and a successful payment row is created. Where it stands The backend runs locally, with Paystack in test mode. Next, I'm connecting the furniture storefront to these APIs, then deploying it. After that, I want to add AI features to the store.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to