# 01 — Kế hoạch tổng thể dự án E-commerce Phần 1
Tài liệu gốc (#1) của dự án. Mọi tài liệu sau đều tham chiếu về đây. Ngày lập: 13/08/2026 · Trạng thái: Bản nháp 1 — chờ chốt
0. Tóm tắt điều hành
Xây dựng một nền tảng thương mại điện tử từ chức năng cơ bản đến nâng cao, trên Laravel 13 + PHP 8.5 + PostgreSQL 18, theo kiến trúc Modular Monolith (modulith) + Hexagonal bên trong mỗi module, có lộ trình tách service khi — và chỉ khi — có tiêu chí đo được.
Ba quyết định lớn nhất:
| # | Quyết định | Lý do một câu |
|---|---|---|
| 1 | Modular Monolith, không microservices từ đầu | Ranh giới nghiệp vụ e-commerce chưa ổn định ở giai đoạn đầu; tách sai còn đắt hơn không tách. Ta cưỡng chế ranh giới bằng công cụ (deptrac + arch test) thay vì bằng network boundary. |
| 2 | PostgreSQL 18, không MySQL | E-commerce cần đúng những thứ PostgreSQL mạnh nhất: jsonb + GIN cho thuộc tính động, partial/expression index, SKIP LOCKED, pgvector, pg_trgm/unaccent cho tiếng Việt, partitioning khai báo, logical replication cho CDC, RLS. |
| 3 | Công nghệ nặng đi theo tiêu chí kích hoạt, không theo sở thích | Kafka / Temporal / Kubernetes / Event Sourcing đều nằm trong kế hoạch, nhưng mỗi thứ có một điều kiện số hoá cụ thể (mục 17). Đưa vào sớm là cách chắc chắn nhất để giết dự án. |
Cảnh báo thẳng thắn ngay từ đầu: yêu cầu "dùng công nghệ khó nhất hiện nay" là một yêu cầu hợp lệ để học, nhưng nếu áp dụng đồng thời tất cả từ tuần đầu thì xác suất dự án không bao giờ bán được đơn hàng nào là rất cao. Kế hoạch này giải quyết mâu thuẫn đó bằng cách: P0–P3 chạy stack tối giản nhưng làm chuẩn xác những bài toán khó thật sự của e-commerce (chống oversell, idempotency, outbox, ledger kép, tiền tệ, đồng thời), rồi mới xếp chồng hạ tầng phân tán lên trên. Cái khó thật của e-commerce nằm ở tính đúng đắn dữ liệu, không nằm ở số lượng logo trong sơ đồ kiến trúc.
1. Hiện trạng & khoảng cách
1.1 Đã khảo sát được gì
C:\ecommer\
└── example-app\ ← skeleton laravel/laravel mới cài, CHƯA viết gì
├── app/ bootstrap/ config/ database/ public/ resources/ routes/ storage/ tests/ vendor/
├── docs/ ← RỖNG (tài liệu này là file đầu tiên)
├── composer.json ← laravel/framework ^10.10, sanctum ^3.3, phpunit ^10.1
└── .env ← mysql / cache=file / queue=sync / session=file / broadcast=log
- Chưa phải git repository.
.envđang ở cấu hình mặc định — tức là chưa cấu hình gì thật.
1.2 Công cụ đã có trên máy
| Công cụ | Bản đang cài | Đánh giá |
|---|---|---|
| PHP | 8.5.9 (NTS, VC++ x64, OPcache) | Rất tốt — vượt yêu cầu của mọi package trong kế hoạch |
| Composer | 2.10.2 | OK |
| Node | 24.18.0 (LTS) | OK (bản mới nhất là 26, không cần nâng) |
| npm | 11.16.0 | OK |
| Docker | 29.6.1 | OK — bắt buộc cho PostgreSQL/Valkey local |
1.3 Khoảng cách chính
| Khoảng cách | Xử lý ở |
|---|---|
| Laravel 10 → 13 (chênh 3 major) | P0 |
| MySQL → PostgreSQL | P0 |
queue=sync, cache=file, session=file → Redis/Valkey + Horizon |
P0–P2 |
| Không có git, CI, test, static analysis | P0 |
Không có module boundary — mọi thứ đang nằm phẳng trong app/ |
P0–P1 |
2. Nền tảng sự thật về phiên bản (đã verify 13/08/2026)
Toàn bộ số dưới đây đã được kiểm tra bằng lệnh thật (Packagist p2 API, endoflife.date, và chạy trực tiếp trên PHP 8.5.9 của máy), không phải trích từ trí nhớ.
2.1 PHP & framework
| Thành phần | Bản mới nhất | Ghi chú |
|---|---|---|
| PHP | 8.5.9 | EOL 31/12/2029 |
laravel/framework |
v13.25.0 | yêu cầu php ^8.3 · Laravel 13 phát hành 17/03/2026, EOL 17/03/2028 |
laravel/laravel (skeleton) |
v13.9.0 (12/08/2026) | require: php ^8.3, laravel/framework ^13.17, laravel/tinker ^3.0 |
phpunit/phpunit |
^12.5 (trong skeleton 13) | — |
pestphp/pest |
v5.1.1 | yêu cầu php ^8.4 → máy đang 8.5.9 nên dùng được |
Tính năng PHP 8.4/8.5 đã test chạy thật trên máy này:
// 8.5 — pipe operator: OK
$r = 5 |> fn($x) => $x + 1; // 6
// 8.5 — clone with: OK (readonly chỉ sửa được TỪ TRONG class scope)
final class Price {
public function __construct(public readonly int $amount, public readonly string $ccy) {}
public function withAmount(int $a): static { return clone($this, ['amount' => $a]); }
}
// clone($p, ['amount' => 9]) gọi từ ngoài class → Error: Cannot modify readonly property
// 8.4 — property hooks: OK
class A { public string $n = "x" { get => strtoupper($this->n); } }
// 8.4 — asymmetric visibility: OK
class B { public private(set) int $v = 1; }
// array_find / array_first: OK (function_exists = true)
clone with + readonly là combo cực hợp cho Value Object trong tầng Domain (mục 7) — đây là lý do kỹ thuật để dùng PHP 8.5 chứ không phải để cho "mới".
2.2 Package Laravel
| Package | Bản mới nhất | Đưa vào pha |
|---|---|---|
laravel/horizon |
v5.48.3 | P2 |
laravel/octane |
v2.19.0 | P4 |
laravel/scout |
v11.5.0 | P6 |
laravel/pennant |
v1.25.0 | P5 |
laravel/reverb |
v1.11.1 | P4 |
laravel/pulse |
v1.8.0 | P1 |
laravel/nightwatch |
v1.28.7 | P5 (dịch vụ trả phí) |
laravel/sanctum |
v4.3.3 | P1 (php artisan install:api) |
laravel/passport |
v13.7.6 | P8 (chỉ khi mở API cho bên thứ ba) |
filament/filament |
v5.7.6 | P1 |
livewire/livewire |
v4.4.0 | P1 |
inertiajs/inertia-laravel |
v3.3.1 | (thay thế cho Livewire) |
larastan/larastan |
v3.10.0 | P0 |
rector/rector |
2.6.2 | P0 |
spatie/laravel-data |
4.23.0 | P1 |
spatie/laravel-permission |
8.3.0 | P1 |
brick/money |
0.14.1 | P2 |
pgvector/pgvector (PHP) |
v0.2.2 | P6 |
elasticsearch/elasticsearch |
v9.5.0 | P6 (nếu chọn ES) |
typesense/typesense-php |
v6.0.0 | P6 (nếu chọn Typesense) |
spatie/laravel-event-sourcing |
7.15.1 | P8 |
temporal/sdk |
v2.17.1 | P8 |
⚠ Package chưa đạt 1.0 — API có thể vỡ, không đặt vào đường quan trọng:
hirethunk/verbs v0.9.0 · laravel/mcp v0.9.3 · laravel/wayfinder v0.1.21.
Dùng được, nhưng phải pin version chính xác và có phương án rút lui.
Mới trong skeleton Laravel 13: laravel/pao v1.1.4 — "Agent-optimized output for PHP testing tools", tức là output test được tối ưu cho AI agent đọc. Rất hợp với cách làm việc của dự án này (mục 16.8).
2.3 Hạ tầng
| Thành phần | Bản mới nhất | Chọn dùng | Pha |
|---|---|---|---|
| PostgreSQL | 18.6 (18 ra 25/09/2025, EOL 14/11/2030) | ✅ CSDL chính | P0 |
| Valkey | 9.1.1 | ✅ cache/queue/lock | P0 |
| Redis | 8.10 | (thay thế cho Valkey) | — |
| Elasticsearch | 9.5.1 | ⏳ P6 | P6 |
| Apache Kafka | 4.3.1 (KRaft, không cần ZooKeeper) | ⏳ P7 | P7 |
| Kubernetes | 1.36.3 | ⏳ P8 | P8 |
| ClickHouse | 26.7 | ⏳ P7 | P7 |
| Grafana | 13.1.3 | ⏳ P5 | P5 |
| Next.js / React / Tailwind | 16.3 / 19.2 / 4.3 | ⏳ P6 (nếu tách headless) | P6 |
3. Nguyên tắc kiến trúc
Bảy nguyên tắc này là "hiến pháp" — mọi tranh luận thiết kế sau này quy về đây.
- Tính đúng đắn của tiền và tồn kho là bất khả xâm phạm. Không bao giờ đánh đổi lấy hiệu năng. Bán quá số lượng (oversell) và lệch sổ tiền là hai lỗi duy nhất phải coi là P0-incident.
- Ranh giới module được cưỡng chế bằng công cụ, không bằng lời hứa. deptrac + Pest arch test chạy trong CI. Vi phạm ranh giới = CI đỏ = không merge.
- Mọi tác dụng phụ ra bên ngoài đều phải idempotent. Webhook thanh toán, gọi API vận chuyển, gửi email, phát hành hoá đơn — tất cả đi qua
idempotency_keyshoặc outbox. - Không có "sự thật" nào nằm ngoài PostgreSQL ở P0–P6. Elasticsearch, Redis, ClickHouse đều là bản sao có thể dựng lại. Mất chúng = chậm, không = mất dữ liệu.
- Mỗi công nghệ mới phải trả lời được: nó thay thế cái gì đang đau? Nếu câu trả lời là "nó hiện đại" thì hoãn (mục 17).
- Đọc và ghi tách bạch về mặt mô hình trước khi tách về mặt hạ tầng. CQRS ở mức "Command/Query object" từ P1; CQRS ở mức "database riêng" chỉ khi có số đo.
- Tài liệu và test là một phần của định nghĩa "xong". ADR cho quyết định khó đảo, test cho mọi luồng chạm tiền.
4. Sơ đồ kiến trúc tổng thể
Đây là hình thái đích ở P7 (P0–P3 chỉ dùng phần in đậm bên trong khung MODULITH + PostgreSQL + Valkey).
┌──────────────────┐
Người dùng ─── HTTPS ───►│ Cloudflare CDN │ cache tĩnh, WAF, bot mgmt, HTTP/3
└────────┬─────────┘
│
┌────────────▼────────────┐
│ FrankenPHP (Caddy) │ Octane worker mode
│ TLS · HTTP/3 · 103 EH │
└────────────┬────────────┘
│
╔═══════════════════════════════════▼═══════════════════════════════════════╗
║ LARAVEL 13 MODULITH ║
║ ║
║ Presentation: REST /api/v1 (OpenAPI 3.1) │ Filament Admin │ Webhook IN ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ modules/ ║
║ ┌──────────┐┌─────────┐┌──────────┐┌────────┐┌────────┐┌──────────────┐ ║
║ │ Catalog ││ Pricing ││Inventory ││ Cart ││Checkout││ Order │ ║
║ └──────────┘└─────────┘└──────────┘└────────┘└────────┘└──────────────┘ ║
║ ┌──────────┐┌─────────┐┌──────────┐┌────────┐┌────────┐┌──────────────┐ ║
║ │ Payment ││Fulfilment││ Returns ││Customer││Promotion││ Notification │ ║
║ └──────────┘└─────────┘└──────────┘└────────┘└────────┘└──────────────┘ ║
║ ┌──────────┐┌─────────┐┌──────────┐┌────────┐┌────────┐┌──────────────┐ ║
║ │ Review ││ Loyalty ││Tax/Invoice││ Search ││ CMS ││ IAM │ ║
║ └──────────┘└─────────┘└──────────┘└────────┘└────────┘└──────────────┘ ║
║ │ giao tiếp chéo CHỈ qua Application Service hoặc Domain Event ║
╚════════╪═══════════════════════════════════════════════════════════════════╝
│
┌──────┴───────┬─────────────┬──────────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌────────────┐
│Postgre │ │ Valkey │ │ Horizon │ │ Outbox │ │ MinIO/ │ │ OTel │
│SQL 18 │ │ cache/ │ │ queue │ │ relay │ │ S3 │ │ Collector │
│ ★ SoT │ │ lock │ │ worker │ │ worker │ │ media │ │ │
└───┬────┘ └──────────┘ └──────────┘ └─────┬─────┘ └─────────┘ └─────┬──────┘
│ logical replication (WAL) │ │
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌──────────────────┐
│ Debezium │ ─────────► Kafka 4.3 ─────► │Elastic/ │ │ Grafana LGTM │
│ (P7) │ (P7) │Typesense │ │ Loki·Tempo·Mimir │
└────┬─────┘ │ (P6) │ └──────────────────┘
▼ └────────────┘
┌──────────────┐ ★ = single source
│ClickHouse+dbt│ báo cáo OLAP, KHÔNG chạy report trên OLTP of truth
│ (P7) │
└──────────────┘
─── Bên ngoài ───
Thanh toán: VNPay · MoMo · ZaloPay · COD (webhook IN, có HMAC + replay guard)
Vận chuyển: GHN · GHTK · Viettel Post · Ahamove
Hoá đơn: Viettel / VNPT / MISA meInvoice
5. Bounded Context & Context Map
5.1 Danh sách context
| # | Context | Trách nhiệm | Aggregate Root | Domain Event chính phát ra |
|---|---|---|---|---|
| 1 | IAM | Định danh, phiên, phân quyền | User, Role |
UserRegistered, PasswordChanged |
| 2 | Customer | Hồ sơ KH, sổ địa chỉ, nhóm KH | Customer, Address |
CustomerCreated, CustomerTierChanged |
| 3 | Catalog | Sản phẩm, biến thể, danh mục, thương hiệu, media | Product (chứa Variant) |
ProductPublished, VariantCreated, ProductUnpublished |
| 4 | Pricing | Bảng giá, giá theo nhóm KH, giá theo bậc số lượng | PriceList |
PriceChanged |
| 5 | Promotion | Coupon, chương trình KM, flash sale, combo/quà tặng | Campaign, Coupon |
CouponRedeemed, CampaignActivated |
| 6 | Inventory | Tồn kho đa kho, giữ chỗ, lô + hạn sử dụng | StockItem |
StockReserved, StockReleased, StockDeducted, LowStockDetected |
| 7 | Cart | Giỏ hàng (khách + đã đăng nhập), merge giỏ | Cart |
CartItemAdded, CartAbandoned |
| 8 | Checkout | Chốt giá, chốt phí ship, chốt thuế, tạo đơn | CheckoutSession |
CheckoutStarted, CheckoutCompleted |
| 9 | Order | Vòng đời đơn hàng, huỷ, sửa | Order |
OrderPlaced, OrderPaid, OrderCancelled, OrderCompleted |
| 10 | Payment | Giao dịch, hoàn tiền, sổ kế toán kép, đối soát | PaymentTransaction, LedgerTransaction |
PaymentAuthorized, PaymentCaptured, RefundIssued |
| 11 | Fulfilment | Đóng gói, vận đơn, theo dõi giao hàng | Shipment |
ShipmentCreated, ShipmentDelivered, ShipmentFailed |
| 12 | Returns | Đổi/trả, RMA, hoàn tiền | ReturnRequest |
ReturnApproved, ReturnReceived |
| 13 | Tax & Invoice | VAT, hoá đơn điện tử, mã CQT | Invoice |
InvoiceIssued, InvoiceCancelled |
| 14 | Review | Đánh giá, ảnh UGC, kiểm duyệt | Review |
ReviewPublished |
| 15 | Loyalty | Điểm thưởng, hạng thành viên, ví voucher | LoyaltyAccount |
PointsEarned, PointsRedeemed |
| 16 | Search | Chỉ mục tìm kiếm, facet, gợi ý | (read model) | — |
| 17 | CMS | Trang tĩnh, banner, blog, landing | Page, Banner |
PagePublished |
| 18 | Notification | Email, SMS, Zalo ZNS, push | NotificationRequest |
NotificationSent |
| 19 | Analytics | Read model báo cáo | (read model) | — |
| 20 | Seller (P8) | Marketplace đa nhà bán | Seller |
SellerOnboarded |
| 21 | Procurement (P3) | Nhập hàng, NCC, công nợ, giá vốn | PurchaseOrder |
GoodsReceived |
5.2 Context Map
Catalog ──(Published Language: ProductPublished)──► Search
│ ▲
│ Shared Kernel: VariantId, Sku, Money │
▼ │
Pricing ◄──── Customer (nhóm KH quyết định bảng giá) │
│ │
▼ │
Cart ─────► Checkout ──(Customer/Supplier)──► Order │
│ ▲ │ │
│ │ giữ chỗ / nhả chỗ │ │
▼ │ ▼ │
Inventory ◄────────────────────── Fulfilment
│
Checkout ──► Promotion (ACL: chuẩn hoá KM về "khoản giảm")
Order ──► Payment ──► Tax&Invoice ──(ACL)──► [Nhà cung cấp HĐĐT]
Order ──► Returns ──► Payment (hoàn tiền) ──► Inventory (nhập lại)
Mọi context ──► Notification (Published Language: NotificationRequest)
Quy ước ACL (Anticorruption Layer): mọi hệ thống bên ngoài (VNPay, GHN, MISA, Shopee) bắt buộc đi qua một ACL trong Infrastructure/ của module tương ứng. Không có DTO nào của nhà cung cấp được rò rỉ vào tầng Domain. Đây là điều khoản không thương lượng — nó là thứ cho phép đổi nhà cung cấp vận chuyển trong 2 ngày thay vì 2 tháng.
5.3 Ubiquitous Language — bảng thuật ngữ
Bảng này tồn tại vì 80% bug nghiệp vụ e-commerce đến từ hai người hiểu khác nhau về cùng một từ.
| Thuật ngữ | Tiếng Việt trong dự án | Định nghĩa chính xác |
|---|---|---|
| Product | Sản phẩm | Thực thể marketing. Không bán được. Có tên, mô tả, danh mục. |
| Variant | Biến thể | Thứ thực sự bán được. Có giá, tồn kho riêng. VD: "Son A — màu 01 — 3g". |
| SKU | Mã SKU | Mã định danh nghiệp vụ của Variant (không phải Product). Duy nhất toàn hệ thống. |
| Batch / Lot | Lô hàng | Nhóm hàng cùng ngày nhập/hạn dùng. Bắt buộc với mỹ phẩm. |
| Expiry (FEFO) | Hạn sử dụng | Xuất theo lô hết hạn trước (First-Expired-First-Out), không phải FIFO. |
| On hand | Tồn thực tế | Số lượng vật lý đang nằm trong kho. |
| Reserved | Đang giữ chỗ | Đã hứa cho đơn chưa trừ kho. |
| Available | Khả dụng | on_hand − reserved. Đây mới là số hiển thị cho khách. |
| Reserve | Giữ chỗ | Tăng reserved. Có expires_at. Xảy ra lúc checkout. |
| Allocate | Phân bổ | Gán giữ chỗ cụ thể vào một kho/lô cụ thể. |
| Deduct | Trừ kho | Giảm on_hand và giảm reserved. Xảy ra lúc xuất kho. |
| List price | Giá niêm yết | Giá gốc, dùng để gạch ngang. |
| Sale price | Giá bán | Giá sau khuyến mãi cấp sản phẩm. |
| Unit price | Đơn giá | Giá 1 đơn vị đã chốt vào order_item — snapshot, không đổi. |
| Authorize | Chuẩn chi | Ngân hàng giữ tiền, chưa trừ. |
| Capture | Thu tiền | Thực sự trừ tiền khách. |
| Settle | Đối soát | Tiền về tài khoản người bán (T+1, T+2). |
| Void | Huỷ chuẩn chi | Nhả authorize trước khi capture. |
| Refund | Hoàn tiền | Trả tiền sau khi đã capture. |
| Cart | Giỏ hàng | Có thể thay đổi tự do. Giá động. |
| Order | Đơn hàng | Bất biến về giá. Giá đã snapshot. |
| Fulfilment | Thực hiện đơn | Toàn bộ quá trình từ đóng gói đến giao xong. |
| Shipment | Kiện hàng | Một vận đơn. Một đơn có thể có nhiều kiện. |
Chú ý quan trọng nhất trong bảng:
Cart → Orderlà ranh giới nơi giá đóng băng. Mọi tranh chấp "giá đổi lúc khách đang thanh toán" đều xử lý ở đây (mục 8.4).
6. Vòng đời đơn hàng — máy trạng thái
Một đơn hàng có hai trục trạng thái độc lập — gộp chúng làm một là lỗi thiết kế phổ biến nhất:
TRỤC 1 — order.status (nghiệp vụ)
draft ──► pending_payment ──► confirmed ──► processing ──► shipped ──► delivered ──► completed
│ │ │ │ │
└──► cancelled ◄─────┴──────────────┘ │ │
└──► returning ──► returned
TRỤC 2 — order.payment_status TRỤC 3 — order.fulfilment_status
unpaid → authorized → paid unfulfilled → partially_fulfilled → fulfilled
│ │ │ │
└─────────┴──► partially_refunded → refunded └──► cancelled
Bảng chuyển trạng thái hợp lệ (trục 1)
| Từ | Sang | Điều kiện | Tác dụng phụ |
|---|---|---|---|
draft |
pending_payment |
Checkout hoàn tất, đã reserve đủ tồn | Tạo payment_transaction, hẹn giờ huỷ 30′ |
pending_payment |
confirmed |
Webhook PaymentCaptured hoặc COD được duyệt |
Ghi ledger, gửi email, phát OrderPaid |
pending_payment |
cancelled |
Quá 30′ hoặc khách huỷ hoặc thanh toán fail | Nhả reservation |
confirmed |
processing |
Kho nhận đơn | Allocate lô theo FEFO |
processing |
shipped |
Đã tạo vận đơn | Deduct tồn, phát hành hoá đơn, gửi mã vận đơn |
shipped |
delivered |
Webhook hãng vận chuyển | Với COD: ghi nhận phải thu từ hãng |
delivered |
completed |
Hết hạn đổi trả (7–15 ngày) | Cộng điểm loyalty, mở đánh giá |
delivered |
returning |
Khách tạo yêu cầu trả | Tạo ReturnRequest |
confirmed/processing |
cancelled |
Admin huỷ | Nhả reservation + hoàn tiền nếu đã thu |
Nguyên tắc bất biến:
- Không có chuyển tiếp nào ngoài bảng này. Cưỡng chế bằng
enum+ phương thức trên aggregate, không bằng$order->status = '...'. shippedlà điểm không quay lui của tồn kho (deduct). Trước đó chỉ làreserved.- Huỷ một phần đơn = tách đơn (
order.split), không phải sửa trạng thái đơn gốc.
7. Kiến trúc bên trong module — Hexagonal áp dụng thật vào Laravel
7.1 Cấu trúc thư mục đích
ecommerce/ ← đổi tên từ example-app
├── app/ ← chỉ còn phần "vỏ" Laravel
│ ├── Providers/
│ └── Support/ ← tiện ích dùng chung, KHÔNG chứa nghiệp vụ
├── bootstrap/app.php ← Laravel 11+ : thay cho Http/Console Kernel
├── config/
├── database/migrations/ ← chỉ migration của khung (users, jobs, cache)
├── modules/
│ ├── Catalog/
│ │ ├── composer.json ← package nội bộ: "modules/catalog"
│ │ ├── src/
│ │ │ ├── Domain/ ← ⛔ KHÔNG import Illuminate\*
│ │ │ │ ├── Entity/Product.php
│ │ │ │ ├── Entity/Variant.php
│ │ │ │ ├── ValueObject/Sku.php
│ │ │ │ ├── ValueObject/Money.php
│ │ │ │ ├── Event/ProductPublished.php
│ │ │ │ ├── Port/ProductRepository.php ← interface
│ │ │ │ ├── Port/SearchIndexer.php ← interface
│ │ │ │ ├── Service/PricingPolicy.php
│ │ │ │ └── Exception/SkuAlreadyExists.php
│ │ │ ├── Application/ ← use case, điều phối, transaction
│ │ │ │ ├── Command/PublishProduct.php
│ │ │ │ ├── Command/PublishProductHandler.php
│ │ │ │ ├── Query/FindProductBySlug.php
│ │ │ │ └── Dto/ProductData.php ← spatie/laravel-data
│ │ │ ├── Infrastructure/ ← adapter: nơi DUY NHẤT biết Eloquent
│ │ │ │ ├── Persistence/Eloquent/ProductModel.php
│ │ │ │ ├── Persistence/Eloquent/EloquentProductRepository.php
│ │ │ │ ├── Search/TypesenseIndexer.php
│ │ │ │ └── CatalogServiceProvider.php ← bind port → adapter
│ │ │ └── Presentation/
│ │ │ ├── Http/Api/V1/ProductController.php
│ │ │ ├── Http/Api/V1/ProductResource.php
│ │ │ ├── Filament/ProductResource.php
│ │ │ └── Console/ReindexCatalog.php
│ │ ├── database/migrations/
│ │ ├── routes/api.php
│ │ └── tests/{Unit,Feature,Architecture}/
│ ├── Inventory/ ...
│ ├── Order/ ...
│ └── Shared/ ← Shared Kernel: CHỈ VO thuần + contract event
│ └── src/{Money,Sku,VariantId,DomainEvent}.php
├── docs/ ← tài liệu (file này)
├── deptrac.yaml
├── phpstan.neon
├── rector.php
├── pint.json
├── compose.yaml
└── composer.json ← path repository trỏ vào modules/*
7.2 Luật phụ thuộc
Presentation ──┐
├──► Application ──► Domain
Infrastructure ┘ ▲
└──────────────────────┘ (implement Port)
⛔ Domain KHÔNG được biết: Illuminate\*, Eloquent, HTTP, queue, cache
⛔ Module A KHÔNG được import bất cứ class nào của Module B
ngoại trừ: Shared Kernel, và Domain\Event\* của B (để lắng nghe)
Giao tiếp chéo module — chỉ có hai đường hợp lệ:
- Đồng bộ: gọi
Application\Command|Querycủa module kia qua interface đã bind trong container. - Bất đồng bộ: lắng nghe domain event của module kia.
Order không bao giờ làm Product::find($id). Nó gọi CatalogFacade::getVariantSnapshot($variantId) hoặc nhận dữ liệu đã snapshot từ Checkout.
7.3 Khi nào KHÔNG cần Hexagonal
Hexagonal là chi phí. Áp dụng đủ, không áp dụng thừa:
| Module | Mức áp dụng |
|---|---|
Inventory, Order, Payment, Pricing, Promotion |
Hexagonal đầy đủ — logic phức tạp, chạm tiền/tồn |
Catalog, Customer, Returns, Fulfilment |
Hexagonal nhẹ — có Domain layer, nhưng Repository có thể trả Eloquent model |
CMS, Banner, Page, Notification |
Laravel thuần — CRUD, dùng thẳng Eloquent + Filament. Ép hexagonal ở đây là lãng phí thuần tuý. |
7.4 Cưỡng chế ranh giới bằng công cụ
deptrac.yaml:
deptrac:
paths: ['./modules', './app']
layers:
- name: Domain
collectors: [{ type: directory, value: 'modules/[A-Za-z]+/src/Domain/.*' }]
- name: Application
collectors: [{ type: directory, value: 'modules/[A-Za-z]+/src/Application/.*' }]
- name: Infrastructure
collectors: [{ type: directory, value: 'modules/[A-Za-z]+/src/Infrastructure/.*' }]
- name: Presentation
collectors: [{ type: directory, value: 'modules/[A-Za-z]+/src/Presentation/.*' }]
- name: Shared
collectors: [{ type: directory, value: 'modules/Shared/src/.*' }]
- name: Laravel
collectors: [{ type: className, value: '^(Illuminate|Symfony)\\.*' }]
ruleset:
Domain: [Shared] # Domain KHÔNG thấy Laravel
Application: [Domain, Shared]
Infrastructure: [Domain, Application, Shared, Laravel]
Presentation: [Application, Shared, Laravel]
Shared: []
Pest architecture test (tests/Architecture/BoundaryTest.php):
arch('Domain không được phụ thuộc Laravel')
->expect('Modules\*\Domain')
->not->toUse(['Illuminate', 'Symfony']);
arch('Eloquent chỉ sống trong Infrastructure')
->expect('Illuminate\Database\Eloquent\Model')
->toOnlyBeUsedIn('Modules\*\Infrastructure\Persistence');
arch('Module không gọi chéo nhau')
->expect('Modules\Order')
->not->toUse(['Modules\Catalog\Infrastructure', 'Modules\Inventory\Infrastructure']);
arch('Value Object phải bất biến')
->expect('Modules\*\Domain\ValueObject')
->toBeReadonly();
arch('Không có dd/dump/var_dump lọt vào production')
->expect(['dd', 'dump', 'var_dump', 'ray'])->not->toBeUsed();
Cả hai chạy trong CI job static. Vi phạm = CI đỏ.
8. Tầng dữ liệu — PostgreSQL 18
8.1 Vì sao PostgreSQL chứ không phải MySQL
.env hiện tại đang mysql. Đây là quyết định đổi, và chi phí đổi ở thời điểm này là bằng không (chưa có bảng nào). Đổi sau khi có dữ liệu thật sẽ tốn hàng tuần — nên phải quyết ngay ở P0.
| Tính năng PostgreSQL | Dùng vào đâu trong e-commerce này |
|---|---|
jsonb + GIN (jsonb_path_ops) |
Thuộc tính sản phẩm động (mỹ phẩm: loại da, dung tích, thành phần, xuất xứ) mà không cần EAV |
| Partial index | WHERE status='pending_payment' — index chỉ vài nghìn dòng thay vì vài triệu |
| Expression index | Index trên lower(unaccent(name)) cho tìm kiếm tiếng Việt không dấu |
FOR UPDATE SKIP LOCKED |
Hàng đợi job/allocation nhiều worker không giẫm chân nhau |
RETURNING + UPDATE có điều kiện |
Chống oversell nguyên tử trong 1 câu lệnh (mục 8.5) |
| Deferrable constraint trigger | Cưỡng chế sổ kế toán kép cân bằng (mục 8.7) |
pg_trgm + unaccent |
Tìm gần đúng, chịu lỗi chính tả, bỏ dấu tiếng Việt |
pgvector |
Tìm kiếm ngữ nghĩa + gợi ý sản phẩm (P6) |
| Declarative partitioning | Chia orders, stock_movements, outbox_messages theo tháng |
| Logical replication (WAL) | Nền tảng cho Debezium CDC (P7) — MySQL binlog làm được nhưng kém tin cậy hơn về schema evolution |
| Row Level Security | Cách ly dữ liệu nhà bán khi mở marketplace (P8) |
| Generated column | available = on_hand - reserved tính sẵn |
EXCLUDE constraint + WITHOUT OVERLAPS (PG18) |
Chống chồng lấn khoảng thời gian của bảng giá / khuyến mãi |
Materialized view + REFRESH CONCURRENTLY |
Báo cáo doanh thu ngày mà không khoá bảng |
| CTE + window function | Báo cáo cohort, RFM, top sản phẩm — viết được trong SQL thay vì kéo về PHP |
uuidv7() sẵn trong core (PG 18) |
ID công khai có thứ tự thời gian, không cần thư viện ngoài |
Kiểm chứng nhanh sau khi dựng container:
SELECT version(); SELECT uuidv7();
8.2 Mô hình hoá sản phẩm — bài toán khó nhất
Ba lựa chọn, và vì sao chọn cái thứ ba:
| Cách | Ưu | Nhược | Kết luận |
|---|---|---|---|
| EAV thuần | Linh hoạt tuyệt đối | Mỗi facet = 1 self-join. Lọc 5 thuộc tính = 5 join trên bảng chục triệu dòng. Không kiểu dữ liệu. | ❌ Loại |
| jsonb thuần | Đơn giản, linh hoạt | Không FK, không constraint, đếm facet chậm, admin UI không biết field nào tồn tại | ❌ Loại |
| Hybrid ✅ | Cột chuẩn hoá cho thứ dùng để lọc/sắp xếp; jsonb cho phần còn lại; bảng attribute_definitions làm registry điều khiển admin UI + validation |
Phải quyết định thuộc tính nào "lên cột" | ✅ Chọn |
Quy tắc quyết định: một thuộc tính được nâng lên thành cột riêng khi nó thoả một trong ba: (a) xuất hiện trong bộ lọc/facet, (b) dùng để sắp xếp, (c) tham gia ràng buộc nghiệp vụ. Còn lại nằm trong jsonb.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL DEFAULT uuidv7(),
brand_id bigint REFERENCES brands(id),
type text NOT NULL DEFAULT 'simple', -- simple | configurable | bundle | giftcard
status text NOT NULL DEFAULT 'draft', -- draft | active | archived
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT products_public_id_key UNIQUE (public_id)
);
-- Chỉ index sản phẩm còn sống & đang bán: index nhỏ hơn ~10x
CREATE INDEX products_active_idx ON products (published_at DESC)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX products_attributes_gin ON products USING gin (attributes jsonb_path_ops);
-- i18n tách bảng riêng (KHÔNG dùng jsonb đa ngữ):
-- lý do: cần slug UNIQUE theo từng locale, cần FTS index riêng theo locale,
-- và cần biết bản dịch nào còn thiếu.
CREATE TABLE product_translations (
product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE,
locale text NOT NULL, -- 'vi', 'en'
name text NOT NULL,
slug text NOT NULL,
description text,
search_vec tsvector GENERATED ALWAYS AS (
to_tsvector('simple', unaccent(coalesce(name,'') || ' ' || coalesce(description,'')))
) STORED,
PRIMARY KEY (product_id, locale),
CONSTRAINT product_translations_slug_key UNIQUE (locale, slug)
);
CREATE INDEX product_tr_fts_idx ON product_translations USING gin (search_vec);
CREATE INDEX product_tr_trgm_idx ON product_translations USING gin (lower(unaccent(name)) gin_trgm_ops);
CREATE TABLE product_variants (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL DEFAULT uuidv7(),
product_id bigint NOT NULL REFERENCES products(id) ON DELETE CASCADE,
sku text NOT NULL,
barcode text,
-- thuộc tính phân biệt biến thể (mỹ phẩm: màu / dung tích)
options jsonb NOT NULL DEFAULT '{}'::jsonb,
weight_gram integer NOT NULL DEFAULT 0,
-- các cột "nâng lên" vì dùng để lọc/sắp xếp:
volume_ml numeric(10,2),
shade_code text,
requires_batch_tracking boolean NOT NULL DEFAULT true, -- mỹ phẩm: true
shelf_life_days integer,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT variants_sku_key UNIQUE (sku),
CONSTRAINT variants_public_id_key UNIQUE (public_id)
);
-- Registry điều khiển admin UI + validation cho phần jsonb
CREATE TABLE attribute_definitions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE, -- 'skin_type', 'origin_country'
label_vi text NOT NULL,
data_type text NOT NULL, -- string|number|bool|enum|date
allowed_values jsonb,
is_filterable boolean NOT NULL DEFAULT false, -- true ⇒ cân nhắc nâng thành cột
is_required boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0
);
Danh mục — chọn Closure Table (thay vì adjacency list thuần hoặc ltree):
CREATE TABLE categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id bigint REFERENCES categories(id),
sort_order integer NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'active'
);
-- Trả lời "tất cả sản phẩm trong nhánh này" bằng 1 JOIN, không đệ quy
CREATE TABLE category_closure (
ancestor_id bigint NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
descendant_id bigint NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
depth smallint NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
CREATE INDEX category_closure_desc_idx ON category_closure (descendant_id, depth);
Lý do chọn closure table: truy vấn "sản phẩm trong toàn bộ nhánh danh mục" và đếm facet theo nhánh là truy vấn nóng nhất của trang danh mục. Closure table biến nó thành một JOIN phẳng có index. ltree cũng làm được và gọn hơn, nhưng di chuyển nhánh phải viết lại path của mọi hậu duệ và khó tối ưu đếm facet.
8.3 Quyết định xuyên suốt
| Vấn đề | Quyết định | Lý do |
|---|---|---|
| Khoá chính | bigint GENERATED ALWAYS AS IDENTITY nội bộ + public_id uuid DEFAULT uuidv7() cho mọi thứ lộ ra ngoài |
bigint giữ index nhỏ, JOIN nhanh, không phân mảnh B-tree. UUIDv7 công khai chặn liệt kê tuần tự (đối thủ đếm số đơn/ngày qua ID; kẻ tấn công dò IDOR). UUIDv7 có thứ tự thời gian nên nếu sau này cần làm PK cũng không vỡ index. |
| Tiền | numeric(19,4) + cột currency_code char(3) riêng |
numeric là số thập phân chính xác tuyệt đối, cộng/nhân trong SQL cho báo cáo không sai số. 4 chữ số thập phân đủ cho đơn giá sau chia tách khuyến mãi. VND làm tròn về số nguyên ở tầng hiển thị. Trong PHP dùng brick/money 0.14 (lưu ý: chưa 1.0). Không dùng float — không bao giờ. |
| Thời gian | timestamptz, lưu UTC, hiển thị Asia/Ho_Chi_Minh |
Flash sale, đối soát, hạn dùng đều cần mốc tuyệt đối. |
| Xoá mềm | deleted_at cho dữ liệu tham chiếu (product, customer); không soft-delete cho orders, ledger_entries |
Chứng từ tài chính không được xoá, chỉ được ghi bút toán đảo. |
| Audit | stock_movements và ledger_entries là append-only ledger (đã là audit trail). Các bảng khác dùng trigger ghi *_history. |
Không cần Event Sourcing để có audit trail — đây là lý do hoãn ES đến P8. |
| Isolation level | READ COMMITTED (mặc định PostgreSQL) |
Đủ, vì thiết kế chống oversell dựa trên khoá hàng nguyên tử chứ không dựa trên isolation level (mục 8.5). |
8.4 Snapshot giá — ranh giới Cart/Order
Giỏ hàng tính giá động mỗi lần đọc. Đơn hàng đóng băng giá tại thời điểm CheckoutCompleted.
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
variant_id bigint NOT NULL REFERENCES product_variants(id),
-- SNAPSHOT: các cột dưới đây KHÔNG BAO GIỜ đọc lại từ Catalog/Pricing
sku text NOT NULL,
name text NOT NULL,
unit_price numeric(19,4) NOT NULL,
list_price numeric(19,4) NOT NULL,
discount_total numeric(19,4) NOT NULL DEFAULT 0,
tax_rate numeric(5,4) NOT NULL, -- 0.1000 = VAT 10%
tax_total numeric(19,4) NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
line_total numeric(19,4) NOT NULL,
currency_code char(3) NOT NULL DEFAULT 'VND',
applied_promotions jsonb NOT NULL DEFAULT '[]'::jsonb -- vết truy nguyên KM
);
Xử lý "giá đổi lúc khách đang thanh toán": CheckoutSession giữ một price_snapshot_hash + expires_at (15 phút). Trước khi tạo đơn, tính lại giá và so hash:
- Giá giảm → tự động áp giá mới, thông báo cho khách (không chặn).
- Giá tăng → chặn, hiển thị màn hình xác nhận lại. Không bao giờ âm thầm tính giá cao hơn giá khách đã thấy.
8.5 Chống oversell — thiết kế cốt lõi
Đây là bài toán khó nhất về mặt đúng đắn của toàn dự án. Ba cách và lựa chọn:
| Cách | Đánh giá |
|---|---|
Pessimistic lock (SELECT ... FOR UPDATE rồi mới UPDATE) |
Đúng, nhưng 2 vòng round-trip và giữ khoá lâu hơn cần thiết |
Optimistic (version column + retry) |
Retry storm khi flash sale — hàng trăm request cùng thua |
| UPDATE có điều kiện, nguyên tử, 1 câu lệnh ✅ | Đúng, 1 round-trip, khoá hàng ngắn nhất có thể |
CREATE TABLE stock_levels (
variant_id bigint NOT NULL REFERENCES product_variants(id),
warehouse_id bigint NOT NULL REFERENCES warehouses(id),
on_hand integer NOT NULL DEFAULT 0,
reserved integer NOT NULL DEFAULT 0,
available integer GENERATED ALWAYS AS (on_hand - reserved) STORED,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (variant_id, warehouse_id),
-- LƯỚI AN TOÀN CUỐI CÙNG: dù code có sai, CSDL vẫn không cho âm
CONSTRAINT stock_no_oversell CHECK (reserved >= 0 AND on_hand >= 0 AND reserved <= on_hand)
);
CREATE TABLE stock_reservations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL DEFAULT uuidv7(),
variant_id bigint NOT NULL,
warehouse_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
reference_type text NOT NULL, -- 'checkout_session' | 'order'
reference_id text NOT NULL,
status text NOT NULL DEFAULT 'held', -- held | consumed | released | expired
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
-- CHỐNG GIỮ CHỖ TRÙNG khi webhook/retry gọi lại
CONSTRAINT reservations_unique_ref UNIQUE (reference_type, reference_id, variant_id)
);
CREATE INDEX reservations_sweep_idx ON stock_reservations (expires_at)
WHERE status = 'held';
-- Sổ cái chuyển động kho — APPEND ONLY, không UPDATE, không DELETE
CREATE TABLE stock_movements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
variant_id bigint NOT NULL,
warehouse_id bigint NOT NULL,
batch_id bigint REFERENCES stock_batches(id),
quantity integer NOT NULL, -- + nhập, − xuất
reason text NOT NULL, -- purchase|sale|return|adjustment|transfer|damage
reference_type text, reference_id text,
occurred_at timestamptz NOT NULL DEFAULT now(),
created_by bigint
) PARTITION BY RANGE (occurred_at);
Câu lệnh giữ chỗ — toàn bộ tính đúng đắn nằm ở đây:
UPDATE stock_levels
SET reserved = reserved + :qty,
updated_at = now()
WHERE variant_id = :variant_id
AND warehouse_id = :warehouse_id
AND on_hand - reserved >= :qty -- ⬅ điều kiện được đánh giá LẠI sau khi lấy khoá hàng
RETURNING on_hand - reserved AS available_after;
Vì sao đúng ở mức READ COMMITTED: PostgreSQL khoá hàng khi UPDATE; giao dịch đến sau phải chờ, và khi được chạy tiếp nó đánh giá lại mệnh đề WHERE trên phiên bản hàng mới nhất (EvalPlanQual). Nếu người trước đã lấy hết hàng, mệnh đề sai → 0 dòng bị ảnh hưởng → PHP ném OutOfStock. Không có cửa sổ race.
// Modules/Inventory/src/Application/Command/ReserveStockHandler.php
$affected = DB::update($sql, [...]);
if ($affected === 0) {
throw new InsufficientStock($variantId, $requested);
}
Bộ dọn (sweeper): job chạy mỗi phút, nhả các reservation quá hạn:
WITH expired AS (
SELECT id, variant_id, warehouse_id, quantity
FROM stock_reservations
WHERE status = 'held' AND expires_at < now()
FOR UPDATE SKIP LOCKED -- ⬅ nhiều worker chạy song song không giẫm nhau
LIMIT 500
)
UPDATE stock_levels sl
SET reserved = sl.reserved - e.quantity
FROM expired e
WHERE sl.variant_id = e.variant_id AND sl.warehouse_id = e.warehouse_id;
Test bắt buộc (P2, không được bỏ qua): 100 request đồng thời mua sản phẩm còn đúng 1 cái → phải có chính xác 1 thành công và 99 nhận InsufficientStock. Test này chạy trên PostgreSQL thật, không phải SQLite.
8.6 Lô hàng & hạn sử dụng (FEFO) — bắt buộc với mỹ phẩm
CREATE TABLE stock_batches (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
variant_id bigint NOT NULL REFERENCES product_variants(id),
warehouse_id bigint NOT NULL REFERENCES warehouses(id),
batch_code text NOT NULL,
manufactured_at date,
expires_at date NOT NULL,
quantity integer NOT NULL CHECK (quantity >= 0),
unit_cost numeric(19,4) NOT NULL, -- giá vốn theo lô → tính lãi gộp chính xác
received_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT batches_unique UNIQUE (variant_id, warehouse_id, batch_code)
);
-- Truy vấn phân bổ FEFO: lô hết hạn sớm nhất được xuất trước
CREATE INDEX batches_fefo_idx ON stock_batches (variant_id, warehouse_id, expires_at)
WHERE quantity > 0;
Quy tắc nghiệp vụ đi kèm: không bán lô còn dưới N ngày hạn dùng (N cấu hình theo ngành hàng, mỹ phẩm thường 90–180 ngày); cảnh báo khi lô sắp hết hạn; báo cáo tồn theo hạn dùng. Đây là thứ mà 90% kế hoạch e-commerce mẫu bỏ sót và là thứ gây thiệt hại tài chính thật.
All rights reserved