# 03 — Mô hình nghiệp vụ (Domain Model) Phần 1
Tài liệu #2. Tham chiếu:
01-master-plan.md— mục 5 (bounded context), mục 6 (vòng đời đơn hàng), mục 7 (hexagonal), mục 8 (tầng dữ liệu). Ngày lập: 13/08/2026 · Trạng thái: Bản nháp 1 — chờ chốt Phạm vi: tầng Domain và Application. Không nói về hạ tầng, không nói về SQL vật lý (xem03-database-schema.md).
0. Cách đọc tài liệu này
Tài liệu này là hợp đồng nghiệp vụ của dự án. Khi code và tài liệu này mâu thuẫn, một trong hai sai — phải sửa, không được để trôi.
Mỗi bounded context được mô tả theo đúng một khuôn mẫu:
| Mục | Nghĩa |
|---|---|
| Loại | Core / Supporting / Generic — quyết định mức đầu tư công sức |
| Chịu trách nhiệm | Những câu hỏi mà context này là nơi duy nhất trả lời được |
| KHÔNG chịu trách nhiệm | Ranh giới — phần hay bị lấn nhất |
| Aggregate | Đơn vị nhất quán giao dịch. Một transaction = một aggregate |
| Value Object | Kiểu dữ liệu bất biến, tự kiểm tra tính hợp lệ |
| Bất biến (Invariant) | Điều luôn đúng. Vi phạm = ném exception, không bao giờ lưu |
| Lệnh (Command) | Ý định thay đổi trạng thái |
| Sự kiện phát ra | Sự thật đã xảy ra, thì quá khứ, bất biến |
| Sự kiện lắng nghe | Phản ứng với context khác |
| Port | Interface ra thế giới bên ngoài (Domain định nghĩa, Infrastructure hiện thực) |
Quy ước ký hiệu:
⚑= bất biến nghiêm ngặt, phải có test riêng⚠= chỗ dễ mô hình hoá sai, đã có người trả giá⏳= hoãn đến pha sau, ghi ở đây để không quên
1. Nguyên tắc mô hình hoá
Bảy quy tắc dưới đây được áp dụng không ngoại lệ. Chúng là lý do mô hình này không rã ra sau 6 tháng.
1.1 Một transaction = một aggregate
Không bao giờ sửa hai aggregate trong cùng một transaction cơ sở dữ liệu. Nếu cần, dùng domain event + outbox (doc 01 §8.8) để aggregate thứ hai được cập nhật ở transaction sau, chấp nhận nhất quán cuối (eventual consistency).
Hệ quả thực tế: Order và StockLevel không được cập nhật trong cùng một transaction. Quy trình đúng: giữ chỗ tồn trước (transaction 1) → tạo đơn (transaction 2) → nếu tạo đơn thất bại thì nhả chỗ (bù trừ).
⚠ Đây là quy tắc bị vi phạm nhiều nhất vì "chỉ thêm một dòng $stock->save() thôi mà". Chính một dòng đó biến deadlock thành chuyện thường ngày khi tải lên.
1.2 Aggregate nhỏ nhất có thể
Aggregate là ranh giới khoá, không phải ranh giới hiển thị. Aggregate to = tranh chấp khoá cao = thông lượng thấp.
| Cám dỗ sai | Đúng |
|---|---|
Product chứa tất cả Variant và tồn kho của chúng |
Product chứa Variant (ít, đổi cùng nhau). Tồn kho là aggregate riêng (StockItem), vì nó đổi hàng nghìn lần trong khi product đứng yên |
Customer chứa toàn bộ Order |
Order là aggregate riêng. Customer chỉ giữ CustomerId |
Order chứa Shipment và Payment |
Ba aggregate riêng, nối bằng OrderId và domain event |
1.3 Tham chiếu chéo aggregate bằng ID, không bằng object
// ✗ SAI — kéo cả aggregate khác vào, mở cửa cho sửa xuyên ranh giới
final class Order { private Customer $customer; }
// ✓ ĐÚNG
final class Order { private CustomerId $customerId; }
1.4 Bất biến được cưỡng chế trong constructor và phương thức, không ở tầng validation
Validation HTTP chặn dữ liệu rác. Bất biến nghiệp vụ chặn trạng thái vô nghĩa. Hai thứ khác nhau, cả hai đều cần.
// Tầng Presentation: FormRequest kiểm tra "quantity là số nguyên dương"
// Tầng Domain: Order kiểm tra "không thêm dòng vào đơn đã shipped"
Một aggregate được nạp từ CSDL rồi gọi phương thức nghiệp vụ phải không thể rơi vào trạng thái sai, kể cả khi tầng HTTP bị bỏ qua (job, console, seeder, test).
1.5 Domain layer không biết Laravel
Không Illuminate\*, không Eloquent, không now(), không config(), không auth(). Thời gian và định danh người dùng được truyền vào như tham số.
// ✗ SAI: $this->paidAt = now();
// ✓ ĐÚNG: public function markPaid(\DateTimeImmutable $at, UserId $by): void
Lý do không phải là "thuần khiết cho đẹp": nó khiến mọi quy tắc nghiệp vụ test được không cần database, không cần thời gian thật, và khiến test chạy trong mili-giây thay vì giây.
1.6 Sự kiện là sự thật quá khứ, lệnh là ý định
| Lệnh (Command) | Sự kiện (Event) | |
|---|---|---|
| Thì | Mệnh lệnh: PlaceOrder |
Quá khứ: OrderPlaced |
| Có thể từ chối? | Có | Không — đã xảy ra rồi |
| Số người nhận | Đúng 1 handler | 0..n listener |
| Chứa gì | Ý định + dữ liệu đầu vào | Sự thật đã chốt + đủ ngữ cảnh cho người nghe |
⚠ Sự kiện không được chứa tham chiếu tới aggregate. Nó chứa dữ liệu đã sao chép. Người nghe không được phép "đi nạp lại object" — trạng thái có thể đã đổi từ lúc sự kiện phát ra.
1.7 Chính sách (Policy) tách khỏi Aggregate
Quy tắc hay đổi (ai được giảm giá bao nhiêu, đơn nào bị coi là rủi ro, khi nào miễn phí ship) nằm trong Policy — object thuần, thay được, test riêng. Aggregate chỉ giữ bất biến không đổi (đơn đã giao không thể quay về nháp).
2. Phân loại context theo mức đầu tư
Không phải context nào cũng xứng đáng với cùng một lượng công sức. Đây là bản đồ đầu tư.
| Loại | Nghĩa | Cách làm | Context |
|---|---|---|---|
| Core | Nơi tạo lợi thế cạnh tranh và nơi mất tiền nếu sai | Hexagonal đầy đủ, coverage ≥ 90%, mutation test, ADR cho mọi quyết định | Inventory, Order, Payment, Pricing, Promotion, Checkout |
| Supporting | Riêng của mình nhưng không tạo lợi thế | Hexagonal nhẹ: có Domain layer, Repository có thể trả Eloquent model | Catalog, Customer, Fulfilment, Returns, Loyalty, Procurement, Tax & Invoice |
| Generic | Ai cũng làm giống nhau, mua/dùng lại được | Laravel thuần + package có sẵn. Ép hexagonal ở đây là lãng phí | IAM, CMS, Notification, Review, Search, Analytics |
Quy tắc ngân sách: nếu bạn đang viết Value Object cho
CMS, bạn đang tiêu công sức sai chỗ. Nếu bạn đang dùng$order->status = 'paid'trongOrder, bạn đang tiết kiệm sai chỗ.
3. Value Object dùng chung (Shared Kernel)
Đây là toàn bộ nội dung được phép nằm trong modules/Shared — thêm bất cứ thứ gì khác vào đây là bắt đầu tạo ra một monolith trá hình.
3.1 Danh mục
| Value Object | Bất biến tự kiểm | Ghi chú |
|---|---|---|
Money |
currency hợp lệ ISO-4217; phép cộng chỉ giữa cùng tiền tệ |
Bọc brick/money, không tự tính bằng float |
Quantity |
> 0 với dòng hàng; >= 0 với tồn kho |
Hai kiểu riêng: PositiveQuantity, StockQuantity |
Sku |
3–64 ký tự, [A-Z0-9._-], tự chuẩn hoá hoa |
Định danh nghiệp vụ của Variant |
VariantId, ProductId, OrderId, CustomerId… |
UUIDv7 hợp lệ | Kiểu riêng cho từng loại — chặn truyền nhầm ID |
EmailAddress |
RFC + chuẩn hoá thường | |
PhoneNumberVn |
Chuẩn hoá về +84…, chấp nhận 0… đầu vào |
⚠ Quan trọng: khách nhập 0987…, cổng SMS/ZNS cần +84987… |
AddressSnapshot |
Bất biến hoàn toàn, chứa văn bản đầy đủ | Xem §3.3 |
Percentage |
0 <= v <= 100, numeric(5,4) |
|
DateRange |
from < to |
Dùng cho bảng giá, khuyến mãi |
TaxRate |
0 <= v <= 1, kèm valid_from |
Xem §14 |
Slug |
thường, [a-z0-9-], đã bỏ dấu tiếng Việt |
3.2 Money — mẫu chuẩn cho mọi Value Object
Đây là mẫu thể hiện cách dùng PHP 8.5 trong tầng Domain. Mọi VO khác viết theo cùng phong cách.
<?php
declare(strict_types=1);
namespace Modules\Shared\Domain;
/**
* Số tiền bất biến. KHÔNG dùng float ở bất kỳ đâu.
* Đơn vị lưu trữ: numeric(19,4) trong PostgreSQL (doc 01 §8.3).
*/
final readonly class Money implements \JsonSerializable
{
private function __construct(
public string $amount, // chuỗi thập phân chính xác, vd "159000.0000"
public string $currency, // ISO-4217, vd "VND"
) {}
public static function of(string|int $amount, string $currency = 'VND'): self
{
$currency = strtoupper($currency);
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException("Mã tiền tệ không hợp lệ: {$currency}");
}
if (!preg_match('/^-?\d+(\.\d{1,4})?$/', (string) $amount)) {
throw new \InvalidArgumentException("Số tiền không hợp lệ: {$amount}");
}
return new self(number_format((float) $amount, 4, '.', ''), $currency);
}
public static function zero(string $currency = 'VND'): self
{
return new self('0.0000', $currency);
}
public function plus(self $other): self
{
$this->assertSameCurrency($other);
// clone with (PHP 8.5) — hợp lệ vì gọi TỪ TRONG class scope
return clone($this, ['amount' => bcadd($this->amount, $other->amount, 4)]);
}
public function minus(self $other): self
{
$this->assertSameCurrency($other);
return clone($this, ['amount' => bcsub($this->amount, $other->amount, 4)]);
}
public function multipliedBy(int $factor): self
{
return clone($this, ['amount' => bcmul($this->amount, (string) $factor, 4)]);
}
public function isNegative(): bool { return bccomp($this->amount, '0', 4) < 0; }
public function isZero(): bool { return bccomp($this->amount, '0', 4) === 0; }
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return bccomp($this->amount, $other->amount, 4) > 0;
}
public function equals(self $other): bool
{
return $this->currency === $other->currency
&& bccomp($this->amount, $other->amount, 4) === 0;
}
/** VND không có đơn vị phụ — làm tròn về số nguyên khi hiển thị/xuất hoá đơn. */
public function toDisplayUnits(): string
{
return $this->currency === 'VND'
? bcadd($this->amount, '0', 0)
: bcadd($this->amount, '0', 2);
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \DomainException(
"Không thể tính toán giữa {$this->currency} và {$other->currency}"
);
}
}
public function jsonSerialize(): array
{
return ['amount' => $this->amount, 'currency' => $this->currency];
}
}
Vì sao private function __construct + static factory: ép mọi đường tạo object đi qua kiểm tra hợp lệ. Không có cách nào tạo ra một Money không hợp lệ trong toàn hệ thống.
Vì sao bcmath thay vì float: 0.1 + 0.2 !== 0.3. Ở e-commerce, sai số này tích luỹ thành lệch sổ và không bao giờ tìm ra nguyên nhân.
3.3 AddressSnapshot — ⚠ điểm dễ sai nhất
final readonly class AddressSnapshot
{
public function __construct(
public string $recipientName,
public string $phone, // đã chuẩn hoá +84
public string $line1, // số nhà, đường
public ?string $ward, // phường/xã — VĂN BẢN, không phải FK
public ?string $district, // ⚠ có thể null với địa chỉ tạo sau khi bỏ cấp huyện
public string $province, // tỉnh/thành — VĂN BẢN
public string $countryCode, // 'VN'
public ?string $wardCode, // mã tại thời điểm chụp, để tra cứu lịch sử
public ?string $provinceCode,
public ?string $note,
) {}
}
Ba lý do đây là snapshot văn bản, không phải khoá ngoại sang bảng địa chỉ (doc 01 §12.2):
- Đơn vị hành chính Việt Nam đã được tổ chức lại (sáp nhập tỉnh, chuyển sang mô hình hai cấp). Nếu
orderstrỏ FK sang bảng địa chỉ, mọi đơn cũ sẽ đổi địa chỉ khi bảng được cập nhật — hoá đơn in ra sai so với lúc giao. - Khách sửa sổ địa chỉ của họ → đơn cũ không được đổi theo.
- Hãng vận chuyển cập nhật danh mục ở thời điểm khác nhau →
wardCodegiữ lại để tra cứu, nhưng văn bản mới là sự thật.
⚑ Bất biến: một khi Order được tạo, AddressSnapshot của nó không bao giờ đổi. Muốn đổi địa chỉ giao → tạo bản ghi thay đổi riêng (order_address_changes), không sửa đè.
4. Context: Catalog
Loại: Supporting · Module: modules/Catalog
Chịu trách nhiệm: sản phẩm bán cái gì, mô tả ra sao, thuộc danh mục nào, có những biến thể nào, ảnh gì.
KHÔNG chịu trách nhiệm: giá (→ Pricing), còn hàng không (→ Inventory), bán chạy không (→ Analytics).
⚠ Đây là ranh giới bị lấn nhiều nhất. Cám dỗ đặt price và stock vào bảng products rất lớn vì "trang danh sách cần hiển thị cả ba". Giải pháp đúng là read model cho trang danh sách (§21), không phải gộp aggregate.
4.1 Aggregate
Product (aggregate root)
├── ProductId, status, type
├── Translations[locale] : ProductTranslation (name, slug, description)
├── Variants[] : Variant (entity con)
│ ├── VariantId, Sku, options{}, weight
│ ├── requiresBatchTracking, shelfLifeDays
│ └── status
├── attributes : jsonb (điều khiển bởi AttributeDefinition)
├── categoryIds[] : CategoryId[] (tham chiếu bằng ID)
└── media[] : MediaId[]
Category, Brand, AttributeDefinition, Media là các aggregate riêng.
4.2 Bất biến
| # | Bất biến |
|---|---|
| ⚑ C1 | Sku duy nhất toàn hệ thống. Kiểm ở Domain (qua Port) và UNIQUE ở CSDL |
| ⚑ C2 | Product không thể active nếu không có ít nhất 1 Variant active |
| ⚑ C3 | Product không thể active nếu thiếu bản dịch của locale mặc định (vi) |
| ⚑ C4 | Không xoá cứng Variant đã từng xuất hiện trong order_items — chỉ archived |
| C5 | slug duy nhất theo từng locale |
| C6 | Sản phẩm type=configurable phải có ≥ 2 variant; type=simple có đúng 1 |
| C7 | Thuộc tính trong attributes phải khớp AttributeDefinition (kiểu + giá trị cho phép) |
C4 là bất biến chống mất dữ liệu lịch sử: xoá variant → hoá đơn cũ không tra được sản phẩm.
4.3 Lệnh & sự kiện
| Lệnh | Sự kiện phát ra |
|---|---|
CreateProduct |
catalog.product.created |
AddVariant |
catalog.variant.created |
UpdateProductContent |
catalog.product.content_updated |
PublishProduct |
catalog.product.published ⭐ |
UnpublishProduct |
catalog.product.unpublished ⭐ |
ArchiveVariant |
catalog.variant.archived |
AssignCategories |
catalog.product.categorized |
⭐ = sự kiện có nhiều người nghe, cần thiết kế payload cẩn thận (§22).
Lắng nghe: không lắng nghe gì. Catalog là thượng nguồn.
4.4 Port
interface ProductRepository {
public function findById(ProductId $id): ?Product;
public function findBySku(Sku $sku): ?Product;
public function skuExists(Sku $sku): bool;
public function save(Product $product): void;
public function nextIdentity(): ProductId;
}
interface SearchIndexer { // Infrastructure: PostgreSQL FTS → Typesense (P6)
public function index(ProductId $id): void;
public function remove(ProductId $id): void;
}
5. Context: Pricing
Loại: Core · Module: modules/Pricing
Chịu trách nhiệm: một Variant cụ thể, với một Customer cụ thể, ở một thời điểm cụ thể, có giá bao nhiêu.
KHÔNG chịu trách nhiệm: giảm giá theo mã/chương trình (→ Promotion). Phân biệt này rất quan trọng và hay bị gộp.
| Pricing | Promotion | |
|---|---|---|
| Trả lời | "Giá gốc của món này với khách này là bao nhiêu" | "Khách này được giảm thêm bao nhiêu" |
| Phạm vi | Từng dòng hàng | Từng dòng hoặc cả đơn |
| Điều kiện | Nhóm KH, số lượng, thời gian | Mã, giỏ hàng, hạng thành viên, tồn kho |
| Kết quả | list_price + sale_price |
Danh sách Discount áp lên dòng/đơn |
5.1 Aggregate
PriceList (aggregate root)
├── PriceListId, code, currency, priority
├── customerGroupIds[] (null = áp cho tất cả)
├── DateRange validity
└── PriceRule[]
├── variantId
├── minQuantity (giá theo bậc số lượng)
├── listPrice, salePrice : Money
└── DateRange validity (khuyến mãi giá cấp sản phẩm)
5.2 Bất biến
| # | Bất biến |
|---|---|
| ⚑ P1 | salePrice <= listPrice |
| ⚑ P2 | salePrice >= 0 |
| ⚑ P3 | Hai PriceRule cùng (variantId, minQuantity, customerGroup) không được chồng lấn thời gian — cưỡng chế bằng EXCLUDE ... WITH ... WITHOUT OVERLAPS ở CSDL |
| P4 | minQuantity >= 1, các bậc không trùng nhau |
| P5 | Mọi PriceList phải cùng currency với đơn hàng đang tính |
P3 là lý do PostgreSQL 18 được chọn — CSDL tự chặn hai bảng giá chồng nhau, không cần code kiểm tra.
5.3 Thuật toán giải giá
resolvePrice(variantId, customerId, quantity, at) :
1. Lấy customerGroupId của khách (mặc định: 'guest')
2. Lọc các PriceList: đang hiệu lực tại `at` AND áp cho group này
3. Sắp theo priority giảm dần
4. Với PriceList đầu tiên có PriceRule khớp variantId
và minQuantity <= quantity (chọn bậc cao nhất thoả) → trả về
5. Không có → trả về giá mặc định của variant (PriceList 'base')
6. Không có nữa → ném VariantNotPriced (⚑ KHÔNG mặc định về 0)
⚠ Bước 6 quan trọng: một sản phẩm không có giá phải báo lỗi, không được coi là miễn phí. Đây là lỗi kinh điển gây mất tiền thật.
5.4 Sự kiện
| Sự kiện | Người nghe |
|---|---|
pricing.price_changed ⭐ |
Search (reindex), Cache (purge), Analytics |
pricing.price_list_activated |
Search (reindex hàng loạt) |
6. Context: Promotion
Loại: Core · Module: modules/Promotion
⚠ Cảnh báo về độ phức tạp: khuyến mãi luôn phức tạp gấp 5 lần dự tính (doc 01 §17.3). Thiết kế dưới đây cố ý tách "điều kiện" khỏi "hiệu ứng" để tránh mê cung if.
6.1 Aggregate
Campaign (aggregate root)
├── CampaignId, name, priority, stackable: bool
├── DateRange validity
├── Condition[] (tất cả phải thoả — AND)
│ ├── CartSubtotalAtLeast(Money)
│ ├── ContainsVariant(VariantId, minQty)
│ ├── ContainsCategory(CategoryId, minQty)
│ ├── CustomerGroupIs(GroupId[])
│ ├── CustomerTierIs(Tier[])
│ ├── FirstOrderOnly
│ └── UsageLimitNotReached
└── Effect[] (áp dụng khi thoả)
├── PercentOffLine(Percentage, targetSelector)
├── FixedOffLine(Money, targetSelector)
├── PercentOffOrder(Percentage, maxDiscount: ?Money)
├── FreeShipping
├── GiftVariant(VariantId, qty) ← mua X tặng Y
└── BundlePrice(VariantId[], Money) ← combo
Coupon (aggregate root RIÊNG)
├── CouponId, code, campaignId
├── maxRedemptions, maxRedemptionsPerCustomer
├── redemptionCount ← bộ đếm có tranh chấp, xem §6.3
└── DateRange validity
Coupon tách khỏi Campaign vì redemptionCount là điểm nóng ghi, còn Campaign gần như chỉ đọc — gộp lại sẽ khiến mọi lần dùng mã đều khoá cả chương trình.
6.2 Bất biến
| # | Bất biến |
|---|---|
| ⚑ M1 | Tổng giảm giá không bao giờ vượt quá subtotal của đơn. Kết quả cuối luôn >= 0 |
| ⚑ M2 | redemptionCount <= maxRedemptions |
| ⚑ M3 | Một (couponId, customerId) không vượt maxRedemptionsPerCustomer — UNIQUE ở CSDL |
| ⚑ M4 | Campaign stackable = false không được cộng dồn với campaign khác |
| M5 | GiftVariant chỉ tặng khi món quà còn tồn — nếu hết, chương trình vẫn áp phần còn lại |
| M6 | Giảm giá được phân bổ về từng dòng để hoàn tiền một phần tính đúng (§6.4) |
6.3 Chống lạm dụng mã giảm giá
Bộ đếm redemptionCount là tài nguyên tranh chấp giống hệt tồn kho, và giải bằng đúng kỹ thuật đó (doc 01 §8.5):
UPDATE coupons
SET redemption_count = redemption_count + 1
WHERE id = :id
AND (max_redemptions IS NULL OR redemption_count < max_redemptions)
AND now() BETWEEN valid_from AND valid_to
RETURNING redemption_count;
-- 0 dòng → CouponExhausted
Cộng với UNIQUE (coupon_id, customer_id) trên coupon_redemptions cho giới hạn theo khách.
⚑ Bất biến quan trọng: mã được "giữ chỗ" lúc checkout (giống reservation tồn kho) và chỉ được xác nhận tiêu thụ khi đơn chuyển sang confirmed. Đơn huỷ → nhả lượt dùng. Không làm bước này thì mã 100 lượt sẽ bị đốt hết bởi các đơn không bao giờ thanh toán.
6.4 Phân bổ giảm giá về dòng — ⚠ điểm hay bị bỏ qua
Khi giảm 50.000₫ cho cả đơn gồm 3 dòng, số tiền đó phải được chia về từng dòng theo tỷ trọng, vì:
- Trả một phần đơn → phải biết hoàn bao nhiêu cho đúng dòng đó.
- Hoá đơn VAT tính thuế trên giá sau giảm của từng dòng.
- Báo cáo lãi gộp theo sản phẩm.
Chia theo tỷ trọng line_total, phần dư do làm tròn dồn vào dòng có giá trị lớn nhất, và ⚑ tổng sau phân bổ phải bằng đúng số giảm ban đầu (có test riêng cho việc này).
7. Context: Inventory
Loại: Core — quan trọng nhất về tính đúng đắn · Module: modules/Inventory
Chịu trách nhiệm: còn bao nhiêu, ở kho nào, lô nào, giữ chỗ cho ai, hết hạn khi nào.
KHÔNG chịu trách nhiệm: giá vốn để báo cáo lãi (→ Procurement sở hữu unit_cost, Inventory chỉ mang theo).
7.1 Aggregate
StockItem (aggregate root) — khoá theo (VariantId, WarehouseId)
├── onHand : int
├── reserved : int
├── available : int (dẫn xuất = onHand - reserved)
└── version
Reservation (aggregate root RIÊNG)
├── ReservationId, variantId, warehouseId, quantity
├── reference (CheckoutSessionId | OrderId)
├── status: held | consumed | released | expired
└── expiresAt
Batch (aggregate root RIÊNG) — chỉ với variant requiresBatchTracking
├── BatchId, batchCode, expiresAt, quantity, unitCost
└── warehouseId, variantId
StockMovement — APPEND ONLY, không phải aggregate mà là bản ghi sổ
7.2 Bất biến — nhóm nghiêm ngặt nhất của toàn dự án
| # | Bất biến |
|---|---|
| ⚑ I1 | reserved >= 0 |
| ⚑ I2 | onHand >= 0 |
| ⚑ I3 | reserved <= onHand — đây chính là "không oversell" |
| ⚑ I4 | Mọi thay đổi onHand phải có StockMovement tương ứng. Tổng movement = onHand hiện tại |
| ⚑ I5 | Không giữ chỗ hai lần cho cùng (reference, variantId) — UNIQUE ở CSDL |
| ⚑ I6 | Reservation quá hạn phải được nhả trong ≤ 60 giây |
| ⚑ I7 | Với variant có requiresBatchTracking: SUM(batch.quantity) == stockItem.onHand |
| ⚑ I8 | Không phân bổ lô đã hết hạn hoặc còn dưới ngưỡng ngày hạn dùng tối thiểu |
I3 được cưỡng chế ở ba tầng độc lập: CHECK constraint trong PostgreSQL, mệnh đề WHERE của câu UPDATE nguyên tử, và bất biến trong aggregate. Ba tầng vì đây là lỗi duy nhất trong hệ thống mà "hiếm khi xảy ra" vẫn là không chấp nhận được.
I7 là bất biến xuyên aggregate, không cưỡng chế được trong một transaction. Xử lý bằng job đối chiếu chạy hằng đêm, lệch → cảnh báo P0.
7.3 Aggregate StockItem — mã nguồn
<?php
declare(strict_types=1);
namespace Modules\Inventory\Domain\Entity;
use Modules\Inventory\Domain\Exception\InsufficientStock;
use Modules\Shared\Domain\VariantId;
final class StockItem
{
public function __construct(
public readonly VariantId $variantId,
public readonly WarehouseId $warehouseId,
public private(set) int $onHand, // PHP 8.4: đọc công khai, chỉ sửa từ trong
public private(set) int $reserved,
public private(set) int $version,
) {
$this->assertInvariants();
}
public int $available { get => $this->onHand - $this->reserved; } // property hook 8.4
/**
* ⚠ Phương thức này KHÔNG phải nơi chống race condition.
* Chống race nằm ở câu UPDATE nguyên tử trong EloquentStockRepository
* (doc 01 §8.5). Ở đây chỉ là bất biến trong bộ nhớ, để test nhanh
* và để aggregate không bao giờ tự đưa mình vào trạng thái sai.
*/
public function reserve(int $quantity): void
{
$this->assertPositive($quantity);
if ($quantity > $this->available) {
throw InsufficientStock::for($this->variantId, $quantity, $this->available);
}
$this->reserved += $quantity;
$this->assertInvariants();
}
public function release(int $quantity): void
{
$this->assertPositive($quantity);
if ($quantity > $this->reserved) {
throw new \DomainException(
"Nhả nhiều hơn số đang giữ: nhả {$quantity}, đang giữ {$this->reserved}"
);
}
$this->reserved -= $quantity;
$this->assertInvariants();
}
/** Xuất kho thật: giảm CẢ onHand lẫn reserved. */
public function deduct(int $quantity): void
{
$this->assertPositive($quantity);
if ($quantity > $this->reserved) {
throw new \DomainException(
'Chỉ được trừ kho phần đã giữ chỗ. '
. "Trừ {$quantity}, đang giữ {$this->reserved}"
);
}
$this->onHand -= $quantity;
$this->reserved -= $quantity;
$this->assertInvariants();
}
public function receive(int $quantity): void
{
$this->assertPositive($quantity);
$this->onHand += $quantity;
$this->assertInvariants();
}
/** Kiểm kê: đặt lại onHand theo số đếm thực tế. */
public function adjustTo(int $countedQuantity): int
{
if ($countedQuantity < $this->reserved) {
throw new \DomainException(
"Số kiểm kê ({$countedQuantity}) nhỏ hơn số đang giữ chỗ ({$this->reserved}). "
. 'Phải huỷ bớt đơn trước khi điều chỉnh.'
);
}
$delta = $countedQuantity - $this->onHand;
$this->onHand = $countedQuantity;
$this->assertInvariants();
return $delta; // ghi vào StockMovement với reason='adjustment'
}
private function assertPositive(int $q): void
{
if ($q <= 0) {
throw new \InvalidArgumentException("Số lượng phải dương, nhận: {$q}");
}
}
private function assertInvariants(): void
{
// ⚑ I1, I2, I3
if ($this->onHand < 0) throw new \DomainException('onHand âm');
if ($this->reserved < 0) throw new \DomainException('reserved âm');
if ($this->reserved > $this->onHand) throw new \DomainException('reserved > onHand — OVERSELL');
}
}
Chú ý public private(set) (PHP 8.4, đã verify chạy trên máy — doc 01 §2.1): bên ngoài đọc $stock->onHand thoải mái nhưng không thể gán. Không cần viết getter, mà bất biến vẫn được bảo vệ. Đây là lý do kỹ thuật để chọn PHP 8.4+ cho tầng Domain.
7.4 Phân bổ lô theo FEFO
allocateBatches(variantId, warehouseId, quantity, minShelfLifeDays, at) :
1. Lấy các Batch: quantity > 0
AND expiresAt > at + minShelfLifeDays ⚑ I8
sắp xếp theo expiresAt TĂNG DẦN ← FEFO
2. Lấy dần từ lô hết hạn sớm nhất cho đến đủ quantity
3. Không đủ → ném InsufficientAllocatableStock
⚠ KHÁC với InsufficientStock: có hàng nhưng hàng cận date,
cần cảnh báo vận hành chứ không phải báo khách "hết hàng"
4. Trả về BatchAllocation[] { batchId, quantity, unitCost }
unitCost được mang theo để Order chốt giá vốn tại thời điểm xuất — nền tảng của báo cáo lãi gộp chính xác.
7.5 Lệnh & sự kiện
| Lệnh | Sự kiện |
|---|---|
ReserveStock |
inventory.stock_reserved |
ReleaseStock |
inventory.stock_released |
ConsumeReservation |
inventory.stock_deducted ⭐ |
ReceiveStock |
inventory.stock_received |
AdjustStock |
inventory.stock_adjusted |
TransferStock |
inventory.stock_transferred |
| (job định kỳ) | inventory.reservation_expired |
| (job định kỳ) | inventory.low_stock_detected, inventory.batch_expiring_soon |
Lắng nghe: order.cancelled → nhả giữ chỗ · returns.received → nhập kho lại · checkout.session_expired → nhả giữ chỗ.
8. Context: Cart
Loại: Supporting · Module: modules/Cart
Chịu trách nhiệm: khách đang định mua gì. Giá động, tính lại mỗi lần đọc.
KHÔNG chịu trách nhiệm: giữ chỗ tồn kho (⚠ giỏ hàng không giữ chỗ — chỉ checkout mới giữ).
8.1 Bất biến
| # | Bất biến |
|---|---|
| ⚑ K1 | Một khách đã đăng nhập có tối đa 1 giỏ active |
| ⚑ K2 | Không có 2 dòng cùng variantId — cộng dồn số lượng |
| K3 | quantity >= 1; đặt về 0 = xoá dòng |
| K4 | Giỏ chứa variant đã archived → dòng bị đánh dấu unavailable, không tự xoá (khách phải thấy nó biến mất và biết vì sao) |
| K5 | Giỏ khách vãng lai sống 30 ngày; giỏ đã đăng nhập sống 90 ngày |
8.2 Gộp giỏ khi đăng nhập — ⚠ hay làm sai
mergeCarts(guestCart, userCart) :
Với mỗi dòng trong guestCart:
- Nếu userCart đã có variant đó → lấy MAX(qty), không cộng dồn
(khách thêm 2 lúc chưa đăng nhập và 3 lúc đã đăng nhập
thường có nghĩa là muốn 3, không phải 5)
- Nếu chưa có → thêm vào
Kiểm tra lại toàn bộ: variant còn active? còn hàng? giá đổi chưa?
Thông báo rõ cho khách những dòng bị thay đổi.
8.3 Sự kiện
cart.item_added · cart.item_removed · cart.cleared · cart.merged · cart.abandoned (job: không hoạt động 24 giờ và có ≥ 1 dòng → Notification gửi email nhắc)
9. Context: Checkout
Loại: Core · Module: modules/Checkout
Chịu trách nhiệm: biến giỏ hàng thành đơn hàng một cách an toàn. Đây là ranh giới giá đóng băng (doc 01 §8.4).
9.1 Aggregate
CheckoutSession (aggregate root)
├── CheckoutSessionId, cartId, customerId?
├── shippingAddress : AddressSnapshot
├── billingAddress : AddressSnapshot
├── shippingMethod, shippingFee : Money
├── paymentMethod
├── lines[] : CheckoutLine ← ĐÃ SNAPSHOT giá, thuế, giảm giá
├── couponCodes[]
├── priceSnapshotHash : string
├── reservationIds[] : ReservationId[]
├── status : draft | pricing_locked | reserved | completed | expired | failed
└── expiresAt (15 phút)
9.2 Bất biến
| # | Bất biến |
|---|---|
| ⚑ H1 | Không tạo Order nếu chưa giữ chỗ đủ toàn bộ dòng hàng |
| ⚑ H2 | Session hết hạn → bắt buộc nhả toàn bộ reservation |
| ⚑ H3 | priceSnapshotHash phải khớp lúc chốt đơn, nếu không phải tính lại |
| ⚑ H4 | Tổng tiền = Σ line_total + shippingFee − orderDiscount + tax, và >= 0 |
| ⚑ H5 | Địa chỉ giao phải nằm trong vùng phục vụ của phương thức vận chuyển đã chọn |
| H6 | COD chỉ khả dụng khi tổng đơn ≤ hạn mức COD của khách (Policy) |
9.3 Quy trình 8 bước
1. Kiểm tra giỏ → dòng còn bán được không, variant còn active không
2. Giải giá (Pricing) → list_price, sale_price từng dòng
3. Áp khuyến mãi (Promotion) → giữ chỗ lượt dùng coupon, phân bổ giảm giá về dòng
4. Tính phí ship (Fulfilment) → theo địa chỉ + trọng lượng + phương thức
5. Tính thuế (Tax) → theo thuế suất có hiệu lực tại thời điểm này
6. CHỐT: tính priceSnapshotHash, chuyển sang pricing_locked, đặt expiresAt = +15′
7. GIỮ CHỖ (Inventory) → reserve từng dòng.
⚑ Thất bại ở dòng thứ k → nhả toàn bộ 1..k-1 rồi báo lỗi (all-or-nothing)
8. TẠO ĐƠN (Order) → transaction riêng. Thất bại → nhả toàn bộ chỗ đã giữ
⚠ Thứ tự bước 6 trước bước 7 là cố ý. Nếu giữ chỗ trước rồi mới tính giá, một lỗi khi tính giá sẽ để lại chỗ giữ mồ côi. Chốt giá là thao tác không có tác dụng phụ, làm trước thì rẻ khi phải huỷ.
9.4 Xử lý giá đổi giữa chừng
| Tình huống | Xử lý |
|---|---|
| Giá giảm so với lúc khách xem | Tự động áp giá mới, hiện thông báo "giá đã giảm" — không chặn |
| Giá tăng | Chặn, quay lại bước xác nhận, hiển thị chênh lệch rõ ràng. ⚑ Không bao giờ âm thầm tính giá cao hơn giá khách đã thấy |
| Coupon hết hiệu lực giữa chừng | Chặn, thông báo, tính lại tổng |
| Hết hàng ở bước 7 | Chặn, chỉ rõ dòng nào, gợi ý giảm số lượng hoặc bỏ dòng |
9.5 Sự kiện
checkout.session_started · checkout.pricing_locked · checkout.completed ⭐ · checkout.session_expired · checkout.failed
10. Context: Order
Loại: Core · Module: modules/Order
Chịu trách nhiệm: đơn hàng đã chốt: trạng thái, nội dung, lịch sử. Đây là sổ ghi nhận cam kết với khách.
KHÔNG chịu trách nhiệm: thu tiền (→ Payment), giao hàng (→ Fulfilment), trừ kho (→ Inventory). Order điều phối nhưng không thực hiện.
10.1 Ba trục trạng thái độc lập
Nhắc lại từ doc 01 §6, vì đây là quyết định hay bị hiểu nhầm nhất:
status : draft → pending_payment → confirmed → processing → shipped
→ delivered → completed (+ cancelled, returning, returned)
paymentStatus : unpaid → authorized → paid → partially_refunded → refunded
fulfilmentStatus : unfulfilled → partially_fulfilled → fulfilled (+ cancelled)
⚠ Gộp ba trục làm một status duy nhất là lỗi thiết kế phổ biến nhất trong e-commerce. Nó khiến "đơn đã giao nhưng chưa thu được tiền COD" hoặc "đơn đã thanh toán, giao được 2/3 kiện" không biểu diễn được, và cuối cùng người ta thêm cột is_paid, is_shipped, is_partially_... — về đúng chỗ lẽ ra nên bắt đầu.
10.2 Aggregate
Order (aggregate root)
├── OrderId (uuidv7 công khai) + orderNumber (mã khách đọc, vd "HS26081300123")
├── customerId?, customerSnapshot (tên, email, phone tại thời điểm đặt)
├── status, paymentStatus, fulfilmentStatus
├── lines[] : OrderLine ← BẤT BIẾN sau khi tạo
│ ├── variantId, sku, name (snapshot)
│ ├── unitPrice, listPrice : Money (snapshot)
│ ├── discountTotal, taxRate, taxTotal, lineTotal
│ ├── quantity, quantityFulfilled, quantityReturned
│ ├── unitCost : Money (giá vốn lô, snapshot từ Inventory)
│ └── appliedPromotions[] (vết truy nguyên)
├── shippingAddress, billingAddress : AddressSnapshot
├── shippingFee, orderDiscount, subtotal, taxTotal, grandTotal : Money
├── paidAmount, refundedAmount : Money
├── statusHistory[] : StatusTransition (from, to, at, by, reason)
├── placedAt, confirmedAt, shippedAt, deliveredAt, completedAt, cancelledAt
└── cancellationReason?
All rights reserved