The most important workflow in an e-commerce backend is checkout. Product browsing can be eventually improved. Cart UX can be polished later. Checkout is different: once inventory and money are involved, the backend has to be correct.

In this project, the checkout path converts a user’s cart into a pending order, snapshots prices, decrements stock, creates order items, and clears the cart. All of that happens inside one database transaction.

The model design sets up the workflow. Cart belongs one-to-one to a user. CartItem stores a product and quantity, but no price. While an item is in the cart, the live Product.price is used. OrderItem, however, stores price because an order is history. If the product price changes later, the old order must still show what the customer agreed to pay.

That price-snapshot decision is one of the clearest domain lessons from the project. Carts are provisional. Orders are records.

Where Django does the hard work

CreateOrderSerializer.create() owns the checkout transaction. It loads the cart from serializer context, rejects empty carts, then enters transaction.atomic().

Inside the transaction, the code gets the product IDs in the cart and locks those rows with select_for_update(). It builds a product map, computes the total from current locked product prices, creates the Order, validates each line against stock, prepares OrderItem rows with snapshotted prices, decrements stock with F("stock") - quantity, bulk creates order items, and deletes the cart items.

Several Django features matter here:

transaction.atomic() makes the operation all-or-nothing.

select_for_update() prevents two checkouts from reading the same product stock at the same time and both thinking there is enough.

F() expressions update stock in the database instead of trusting a stale Python value.

bulk_create() writes order lines efficiently after validation.

Serializer context passes the current user and cart into the write serializer without letting the client choose ownership.

Those are not framework decorations. They are the difference between a demo checkout and a checkout that can survive concurrent users.

Validation happens twice for a reason

The cart item serializer validates requested quantity against available stock when a user adds or updates a cart line. That gives fast feedback and prevents obviously invalid carts.

Checkout still validates again. It has to. Stock can change between adding to cart and placing the order. Another customer may buy the same product. An admin may adjust inventory. The only validation that really protects stock is the validation inside the transaction, while the relevant product rows are locked.

That two-layer pattern shows up in many domains. Early validation improves UX. Transactional validation protects correctness.

Tradeoffs I noticed

Putting checkout logic in a serializer is idiomatic enough for DRF, but it has a limit. As the workflow grows, a dedicated domain service would be easier to test without HTTP serializer concerns. For this project, the serializer kept the implementation close to the API action and avoided an extra abstraction. If refunds, shipping reservations, coupons, or multi-step payment capture were added, I would move checkout into a service.

Another tradeoff is order status. The order starts as pending after stock is decremented, then payment later marks it success. That keeps inventory reserved once the customer reaches payment, but it raises product questions: what happens to abandoned pending orders? Should stock be restored after a timeout? The current project does not implement expiry for unpaid orders. A production version would need that lifecycle.

The lesson is that backend modeling is product policy. “When do we decrement stock?” is not just a database question. It decides how the store treats abandoned payment sessions, scarcity, and customer fairness.