Hai câu hỏi làm rõ quyết định toàn bộ: giỏ hàng có cần sống sau khi đóng trình duyệt không (có), và khách chưa đăng nhập có được thêm vào giỏ không (có — bắt đăng nhập trước làm giảm chuyển đổi).
Lưu ở đâu: không lưu giỏ hàng chỉ trong localStorage — như vậy không đồng bộ được thiết bị. Lưu phía server, khoá theo cartId:
- Khách chưa đăng nhập: sinh cartId ẩn danh, đặt vào cookie/secure storage của app.
- Khi đăng nhập: gộp giỏ ẩn danh vào giỏ của tài khoản, cộng số lượng theo variantId, rồi xoá giỏ ẩn danh.
carts(id, user_id nullable, updated_at, expires_at)
cart_items(cart_id, variant_id, qty, added_at,
primary key (cart_id, variant_id))Khoá chính (cart_id, variant_id) làm cho "thêm sản phẩm đã có" trở thành một INSERT ... ON CONFLICT DO UPDATE, không cần đọc trước rồi ghi.
Điều quan trọng nhất: giỏ hàng chỉ lưu tham chiếu, không lưu giá. Lưu variant_id và số lượng; giá, khuyến mãi, tồn kho được tính lại lúc hiển thị và kiểm tra lại lần cuối lúc tạo đơn. Lưu giá vào giỏ dẫn tới việc khách thanh toán bằng giá cũ của hôm qua.
Đồng bộ đa thiết bị: client giữ bản sao cục bộ để hiển thị ngay (offline-first), gửi thay đổi lên server và đồng bộ lại khi mở app. Xung đột thì chọn quy tắc đơn giản: hợp nhất theo variant_id, lấy số lượng lớn hơn — với giỏ hàng, mất món khách đã thêm khó chịu hơn là thừa một món.
Điểm nghẽn và đánh đổi:
- Giỏ ẩn danh sinh ra rất nhiều bản ghi rác → đặt expires_at (30-90 ngày) và job dọn.
- Giỏ hàng ghi nhiều, đọc nhiều, dữ liệu nhỏ → có thể để Redis với TTL cho giỏ ẩn danh và Postgres cho giỏ đã đăng nhập; đơn giản hơn thì chỉ dùng Postgres cho tới khi thực sự nghẽn.