Step 5 — The cart
Per-user rows again, plus one SQL trick that removes a whole branch of Go.
- Write an upsert with
ON CONFLICTto add or increase a cart item. - Read the cart with a join instead of one query per item.
- Validate product and quantity before inserting.
What a cart is, in database terms
A cart is not a special thing. It is rows in cart_items belonging to one user: a product, a quantity, and nothing else. There is no cart object, no session, no expiry: the rows are the cart.
So everything from project two applies unchanged. Every query is scoped with WHERE user_id = $1, and adding to somebody else’s cart has to be impossible rather than merely disallowed.
Adding an item — the naive way, and the good one
Adding a product already in the cart should increase the quantity, not create a second row. The obvious approach is: SELECT to see if it is there, then INSERT or UPDATE. That is two round trips, and a race between the check and the write.
SQL has one statement that does the whole thing, and the UNIQUE (user_id, product_id) constraint from step 1 is what makes it work.
func (r *Repository) AddItem(ctx context.Context, userID, productID int64, qty int) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO cart_items (user_id, product_id, quantity) VALUES ($1, $2, $3) ON CONFLICT (user_id, product_id) DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity`, userID, productID, qty) return err}Reading the cart with a join
The cart rows hold only ids and quantities. To show a cart you also need names, prices and stock, which live on the product. One join brings them together in a single query.
func (r *Repository) Items(ctx context.Context, userID int64) ([]Item, error) { rows, err := r.db.QueryContext(ctx, ` SELECT ci.id, ci.quantity, p.id, p.name, p.price_cents, p.stock FROM cart_items ci JOIN products p ON p.id = ci.product_id WHERE ci.user_id = $1 ORDER BY ci.id`, userID) if err != nil { return nil, err } defer rows.Close() var out []Item for rows.Next() { var i Item if err := rows.Scan(&i.ID, &i.Quantity, &i.ProductID, &i.Name, &i.PriceCents, &i.Stock); err != nil { return nil, err } out = append(out, i) } return out, rows.Err()}Totals belong in Go here, not in SQL
This is a judgement call. The monthly report in project two used SQL SUM because it added up thousands of rows. A cart has a handful, and the join has already brought them into Go. Adding them up there is clearer and costs nothing extra.
The rule is *do the work where the data already is*. For the report that was the database; here it is your Go code.
func (s *Service) View(ctx context.Context, userID int64) (*Cart, error) { items, err := s.repo.Items(ctx, userID) if err != nil { return nil, err } var total int64 for _, i := range items { total += i.PriceCents * int64(i.Quantity) // integer cents, never floats } return &Cart{Items: items, TotalCents: total}, nil}Validate before you insert
func (s *Service) Add(ctx context.Context, userID, productID int64, qty int) error { if qty < 1 || qty > 99 { return fmt.Errorf("%w: quantity must be between 1 and 99", ErrValidation) } p, err := s.products.GetByID(ctx, productID) if err != nil { return err // no such product → 404 } if !p.Active { return fmt.Errorf("%w: that product is not for sale", ErrValidation) } if p.Stock < qty { return fmt.Errorf("%w: only %d left", ErrValidation, p.Stock) } return s.repo.AddItem(ctx, userID, productID, qty)}Try it
curl -s -X POST localhost:8080/v1/cart/items -H "Authorization: Bearer $CUST" \ -d '{"product_id":1,"quantity":2}'# add the same product again — quantity becomes 3, not a second rowcurl -s -X POST localhost:8080/v1/cart/items -H "Authorization: Bearer $CUST" \ -d '{"product_id":1,"quantity":1}'curl -s -H "Authorization: Bearer $CUST" localhost:8080/v1/cart | jq# another customer sees an empty cartcurl -s -H "Authorization: Bearer $CUST2" localhost:8080/v1/cart | jq- A cart is just rows scoped to a user. Nothing about it is special.
ON CONFLICT ... DO UPDATEdoes insert-or-increment in one statement, with no race.- One join beats fetching the cart then looping for each product. That loop is N+1.
- Do the arithmetic where the data is. Small set: Go. Large set: SQL.
- 1Write the add-to-cart the naive way with SELECT then INSERT, and work out where the race is.
- 2Add a product to your cart, delete the product as admin, then view the cart. Decide what should happen.
- 3Try adding a quantity of 0 and of 1000.
- 4Add 20 different products, then check how many queries viewing the cart takes.