0

# 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.

  1. 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.
  2. 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.
  3. 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_keys hoặc outbox.
  4. 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.
  5. 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).
  6. Đọ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.
  7. 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 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 → Order là 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 = '...'.
  • shipped là đ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ệ:

  1. Đồng bộ: gọi Application\Command|Query của module kia qua interface đã bind trong container.
  2. 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_movementsledger_entriesappend-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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí