Dev Skills

Step 5 — The cart

Per-user rows again, plus one SQL trick that removes a whole branch of Go.

Topic 79 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Write an upsert with ON CONFLICT to 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.

internal/adapter/storage/postgres/cart.go — create this file
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
}
ON CONFLICT ... DO UPDATE is called an upsert. One statement, no race, and the branch you would have written in Go disappears.

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.

internal/adapter/storage/postgres/cart.go
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()
}
Fetching the cart rows and then querying each product in a loop is the N+1 problem. One join is one round trip.

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.

internal/core/service/cart.go
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

internal/core/service/cart.go
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)
}
This stock check is a courtesy, not a guarantee. Between here and checkout somebody else can buy the last one, and step 6 has to handle that.

Try it

terminal
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 row
curl -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 cart
curl -s -H "Authorization: Bearer $CUST2" localhost:8080/v1/cart | jq
The short version
  • A cart is just rows scoped to a user. Nothing about it is special.
  • ON CONFLICT ... DO UPDATE does 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.
Remember: A stock check when adding to a cart is a courtesy. The only check that counts is the one inside the checkout transaction.
Build this before moving on
Build the three cart endpoints. Add the same product twice and confirm you get one row with quantity 3. Then check another user sees an empty cart.
Then try these in your own editor
  1. 1Write the add-to-cart the naive way with SELECT then INSERT, and work out where the race is.
  2. 2Add a product to your cart, delete the product as admin, then view the cart. Decide what should happen.
  3. 3Try adding a quantity of 0 and of 1000.
  4. 4Add 20 different products, then check how many queries viewing the cart takes.
Check yourselfTake the Project 3 — Small shop test