Support vs Service — và các thuật ngữ đi kèm (Value Object, DTO, view-model)

Trong một project Laravel lớn, thư mục app/ phình ra rất nhanh. Ngoài Models, Http, Providers mà framework đẻ sẵn, team thường tự thêm Services, Support, Actions, DTO, Repositories… Câu hỏi phỏng vấn hay gặp: class này để ở đâu, và vì sao? Trả lời được câu đó tức là bạn hiểu ranh giới tầng, chứ không phải thuộc lòng tên thư mục.

Chốt trước cho nhanh: Laravel không định nghĩa app/Services hay app/Support. Framework chỉ sinh sẵn app/Http, app/Models, app/Providers. Hai thư mục kia là quy ước của team — nên trong phỏng vấn, điều họ chấm không phải là "tên đúng chuẩn" mà là bạn có một lằn ranh nhất quán hay không.

Bảng thuật ngữ

Thuật ngữNghĩa ngắn gọn (bản dịch nôm na)
ServiceClass làm một việc có chạm ra thế giới bên ngoài: query DB, đọc/ghi cache, gọi HTTP, đẩy queue. Thường được inject qua container.
SupportClass thuần logic, không chạm gì bên ngoài. Cho input, ra output, chạy lại 100 lần vẫn ra y hệt.
Value Object (VO) (đối tượng-giá-trị)Vật thể được định danh bằng giá trị, không bằng id. Bất biến (immutable). Hai VO cùng giá trị thì là một. Ví dụ: khoảng giá 100k–500k.
Entity (thực thể)Ngược lại VO: định danh bằng id. Đổi hết field vẫn là nó. Ví dụ: Product.
DTO (Data Transfer Object — gói dữ liệu chuyển tầng)Túi chứa dữ liệu để đưa qua ranh giới tầng. Không có luật nghiệp vụ, chỉ có field.
View-model (mô hình cho màn hình)Dữ liệu đã nắn đúng hình dạng màn hình cần — Blade chỉ việc in ra, không phải suy nghĩ.
Immutable (bất biến)Tạo xong không sửa được. PHP 8.1+: public readonly.
Pure function (hàm thuần)Cùng input luôn ra cùng output, và không để lại dấu vết gì bên ngoài (không ghi DB, không log, không đọc now()).
Side effect (tác động ra ngoài)Bất cứ thứ gì hàm làm ngoài việc trả về giá trị: ghi DB, gửi mail, xoá cache.
Stateless (không giữ trạng thái)Object không nhớ gì giữa các lần gọi — nên chia sẻ chung một instance được.

1. Nó là gì / chạy thế nào

1.1. Lằn ranh thật sự: có chạm I/O hay không

Nhiều team chia theo cảm tính ("cái này nghe như service"). Lằn ranh dùng được lâu dài chỉ có một câu hỏi: class này có cần thế giới bên ngoài để chạy không?

flowchart TD
  Q["Class này khi chạy có cần
DB / cache / HTTP / queue / file / clock không?"] Q -->|"Có"| S["app/Services
(Service)"] Q -->|"Không"| P["app/Support
(Value Object, DTO, enum, helper thuần)"] S --> S1["Test cần database thật
hoặc phải mock"] P --> P1["Test chỉ cần new + assert
không cần Laravel boot"] style S fill:#3b2f16,stroke:#c99a2e,color:#f4e7c3 style P fill:#16302b,stroke:#3fae8f,color:#d3f2e8

Hệ quả rất cụ thể, không phải lý thuyết:

Trong project laravel-livewire, điều này nhìn thấy được ngay ở test suite:

// tests/Unit/EffectivePriceTest.php  — test một class Support
use App\Support\Deal;
use App\Support\EffectivePrice;
use PHPUnit\Framework\TestCase;   // ← TestCase của PHPUnit, KHÔNG phải Tests\TestCase

class EffectivePriceTest extends TestCase
{
    #[DataProvider('vectors')]
    public function test_matches_the_shared_vector(...) { ... }
}

Chi tiết extends PHPUnit\Framework\TestCase chứ không phải Tests\TestCase chính là bằng chứng rằng EffectivePrice là logic thuần. Nếu một ngày nó cần Cache:: hay DB::, test này gãy ngay — lằn ranh tự bảo vệ chính nó.

1.2. Bảng đối chiếu

Tiêu chíapp/Servicesapp/Support
Chạm DB / cache / HTTPCó — đó là lý do nó tồn tạiKhông
Lấy instance bằng cách nàoContainer inject (__construct / tham số render())new thẳng, hoặc static::
Giữ stateCó thể (builder, cache cục bộ trong request)Bất biến, không giữ state
Đặt tênDanh từ chỉ việc: HomeFeed, PricedVariants, CategoryTreeDanh từ chỉ thứ: Money, PriceRange, StockBand
Test ở đâutests/Feature (cần Postgres)tests/Unit (không cần gì)
Thay thế được khôngCó — mock/fake khi test tầng trênKhông cần mock, nó vốn đã tất định
Số lượng nên cóÍt — mỗi cái là một nguồn dữ liệuNhiều — mỗi khái niệm nghiệp vụ một cái

1.3. Luồng thật trong project laravel-livewire

Trang chi tiết sản phẩm (ProductDetail) dùng cả hai tầng, và cách chúng xếp tầng lên nhau là bản mô tả rõ nhất cho quy ước này:

flowchart TB
  subgraph L["Livewire component — App Livewire ProductDetail"]
    R["render()"]
  end

  subgraph SV["app/Services — có I/O"]
    PV["ProductVariants
cache 15 phút + 4 query"] PR["PricedVariants
cache giá + 1 query tồn kho live"] CT["CategoryTree
recursive CTE + cache"] end subgraph SP["app/Support — logic thuần"] VC["VariantCatalogue
tính tile, axis, lựa chọn"] CV["ChosenVariant (VO)"] EP["EffectivePrice (VO)"] PVo["PricedVariant (DTO)"] BB["BuyBox
nắn ra view-model"] MO["Money
format cents"] end V["Blade view
chỉ in mảng"] R --> PV --> VC R --> PR --> PVo R --> CT PR --> EP VC --> CV R --> BB BB --> MO BB --> V VC --> V style SV fill:#3b2f16,stroke:#c99a2e,color:#f4e7c3 style SP fill:#16302b,stroke:#3fae8f,color:#d3f2e8

Đọc theo chiều mũi tên: Service đi lấy dữ liệu → Support biến dữ liệu thành thứ màn hình cần → Blade chỉ in. Không có mũi tên nào đi ngược từ Support về Service — đó là điều làm cho Support test được mà không cần database.

Sequence của một lần render, cho dễ hình dung thứ tự:

sequenceDiagram
  autonumber
  participant B as Browser
  participant C as ProductDetail
  participant S1 as ProductVariants
  participant S2 as PricedVariants
  participant K as Cache va Postgres
  participant U as VariantCatalogue
  participant X as BuyBox

  B->>C: GET /iphone-p.123
  C->>S1: for(product)
  S1->>K: cache 15 phút cho cấu trúc variant
  S1->>K: query tồn kho LIVE (không cache)
  S1-->>C: VariantCatalogue
  C->>U: sanitise(selections) rồi chosen()
  U-->>C: ChosenVariant hoặc null
  C->>S2: forProduct(product)
  S2->>K: cache giá theo pricing_version
  S2-->>C: mảng PricedVariant
  C->>X: price(product, chosen, priced, missingAxes)
  X-->>C: mảng view-model đã format tiền
  C-->>B: HTML

1.4. Bên trong app/Support có mấy loại?

"Support" không phải một loại class. Nó là chỗ chứa cho nhiều loại khác nhau. Đây là chỗ mà các thuật ngữ Value Object / DTO / enum / helper trở nên có ích:

flowchart LR
  SP["app/Support"] --> A["Value Object
có luật nghiệp vụ + bất biến"] SP --> B["DTO
chỉ mang dữ liệu"] SP --> C["Enum
tập giá trị đóng"] SP --> D["Helper tĩnh
hàm thuần gom lại"] SP --> E["View-model mapper
Model đổi thành mảng"] A --- A1["EffectivePrice, PriceRange, Deal"] B --- B1["PricedVariant, ChosenVariant"] C --- C1["StockBand, TileState, CategoryBrowseSort"] D --- D1["Money, SiteUrl"] E --- E1["ProductCard, BuyBox"]

Value Object — định danh bằng giá trị

Ba tính chất bắt buộc, thiếu cái nào thì nó không phải VO:

  1. Bất biến — tạo xong không sửa (readonly).
  2. So sánh theo giá trị — hai instance cùng field là một, không có id để phân biệt.
  3. Luôn hợp lệ — không tạo được instance sai. Constructor hoặc named constructor chặn từ đầu.
// app/Support/Deal.php — VO gọn nhất trong project
final class Deal
{
    private function __construct(          // ← private: chặn new Deal('bậy', -5)
        public readonly string $type,      // ← readonly: bất biến
        public readonly int $value,
    ) {}

    public static function percent(int $bps): self { return new self('percent', $bps); }
    public static function fixed(int $cents): self { return new self('fixed', $cents); }

    public function isFixed(): bool { return $this->type === 'fixed'; }
}

Named constructor (hàm static thay cho new) là mẹo quan trọng: Deal::percent(1500) đọc ra nghĩa ngay, còn new Deal('percent', 1500) thì người đọc phải đoán. Và vì __construct để private, không tồn tại đường nào tạo ra một Deal sai kiểu — luật được ép ở mức compiler, không phải ở mức "nhớ mà làm đúng".

VO phức tạp hơn — PriceRange (khoảng lọc giá của trang danh mục):

final class PriceRange
{
    public function __construct(
        public readonly ?int $minCents = null,
        public readonly ?int $maxCents = null,
    ) {}

    /** Luật hợp lệ, dùng chung cho cả URL lẫn form — hai chỗ không thể lệch nhau */
    public static function rules(mixed $suppliedMin, float $ceilingDollars): array { ... }

    public static function fromDollars(mixed $min, mixed $max): ?self { ... }

    public function equals(self $other): bool          // ← so sánh theo GIÁ TRỊ
    {
        return $this->minCents === $other->minCents
            && $this->maxCents === $other->maxCents;
    }

    public function toQuery(): array { ... }   // chiếu ra URL:  ?min=25&max=50
    public function label(): string { ... }    // chiếu ra chip: "$25 – $50"
}

Chỗ đáng học nhất ở đây không phải cú pháp mà là một giá trị, nhiều hình chiếu:

flowchart LR
  IN1["URL ?min=25 & max=50"] --> V["PriceRange
(minCents, maxCents)"] IN2["Form nhập tay"] --> V IN3["Bấm preset band"] --> V V --> O1["toQuery() → link phân trang"] V --> O2["label() → chip 'đang lọc'"] V --> O3["equals() → so với band đang chọn"] V --> O4["query WHERE giá"] style V fill:#16302b,stroke:#3fae8f,color:#d3f2e8

Ba nguồn input đổ vào một object, rồi bốn chỗ hiển thị đều đọc từ object đó. Đây là cách VO diệt tận gốc lỗi kinh điển: chip nói "$25–$50" mà URL lại là ?min=25, còn lưới sản phẩm thì lọc theo cái thứ ba. Nếu ba chỗ tự parse chuỗi riêng thì sớm muộn cũng lệch; ở đây thì không có cách nào lệch.

DTO — chỉ mang dữ liệu qua tầng

Khác VO ở chỗ: DTO không có luật, không cần equals(), không cần "luôn hợp lệ". Nó chỉ là túi field có tên, thay cho mảng ['price' => ..., 'sku' => ...] mà IDE không gợi ý được và typo thì âm thầm ra null.

// app/Support/PricedVariant.php — DTO (rút gọn)
final class PricedVariant
{
    public function __construct(
        public readonly int $listCents,
        public readonly int $effectiveCents,
        public readonly ?int $badgePercent,
        public readonly ?int $flashBps = null,
        public readonly ?Carbon $flashStartsAt = null,
        public readonly ?int $poolAllocated = null,
        public readonly ?int $poolClaimed = null,
    ) {}

    public static function listPriceOnly(int $listCents): self { ... }
    public function onSale(): bool { return $this->effectiveCents < $this->listCents; }
}

Ranh giới VO / DTO trong thực tế mờ, và đừng cãi nhau về nó trong phỏng vấn. PricedVariantonSale() — một mẩu luật — nên có người gọi nó là VO. Câu trả lời an toàn: "DTO là túi dữ liệu qua tầng; VO là DTO có thêm luật và có định danh theo giá trị. Cái tên ít quan trọng hơn việc nó bất biến và có một nơi duy nhất định nghĩa nó."

Enum — tập giá trị đóng, kèm hành vi

Từ PHP 8.1, enum là chỗ tốt nhất cho "trạng thái chỉ có N khả năng". Điểm mạnh: match trên enum thì PHPStan bắt được ngay khi bạn thêm case mới mà quên xử lý.

// app/Support/StockBand.php
enum StockBand: string
{
    case Unchosen  = 'unchosen';   // chưa chọn đủ thuộc tính
    case InStock   = 'in';
    case LowStock  = 'low';
    case OutOfStock = 'out';

    public static function forQuantity(?int $quantity): self { ... }

    public function heading(): ?string { ... }   // chữ hiện trên màn hình
    public function isBuyable(): bool { ... }    // có cho bấm "Mua" không
}

Chú ý case Unchosen: nó không phải trạng thái kho, mà là trạng thái giao diện. Gộp chung vào một enum là cố ý — vì màn hình chỉ có một dòng "tình trạng kho", nên nó phải có đúng một nguồn quyết định. Tách ra hai khái niệm thì Blade lại phải tự @if ghép lại, và chỗ ghép đó là chỗ sinh bug.

View-model mapper — biến Model thành mảng cho Blade

// app/Support/ProductCard.php
final class ProductCard
{
    public static function from(Product $product, ?PricedVariant $priced = null): array
    {
        $onSale = $priced?->onSale() ?? false;

        return [
            'name'      => $product->name,
            'price'     => Money::format($priced?->effectiveCents),
            'oldPrice'  => $onSale ? Money::format($priced->listCents) : null,
            'badge'     => $onSale ? "{$priced->badgePercent}% OFF" : null,
            'rating'    => $product->rating_count > 0 ? $product->rating_avg : null,
            'soldOut'   => ! $product->in_stock,
            'href'      => $product->url(),
        ];
    }
}

Một class 30 dòng này giải quyết một bài toán thật: card sản phẩm xuất hiện ở trang chủ (qua HomeFeed) và ở trang danh mục (qua CategoryBrowseCatalog). Hai service khác nhau, cùng một hình dạng card. Nếu mỗi service tự build mảng, thì đến lúc thêm field href, chỉ sửa một chỗ và trang kia lặng lẽ vẫn là link chết. Chi tiết hơn về "Blade nên nhận Model hay array" xem Blade nhận gì — Model, array hay Collection?.

1.5. Service trông như thế nào

// app/Services/HomeFeed.php (rút gọn)
class HomeFeed
{
    public function __construct(protected PricedVariants $pricedVariants) {}
    //                          ↑ Service phụ thuộc Service khác, container tự nối

    public function newArrivals(int $limit = 12): Collection
    {
        return Cache::remember(              // ← I/O: cache
            "home:new-arrivals:{$limit}",
            now()->addMinutes(15),
            fn () => $this->toCards(
                Product::query()->active()->newestFirst()->limit($limit)->get()  // ← I/O: DB
            )
        );
    }
}

Ba dấu hiệu nhận biết Service, đủ cả ở đây: Cache::, có query, nhận dependency qua constructor. Và nó trả về mảng card (do ProductCard nắn), không trả Eloquent model ra ngoài — ranh giới tầng được giữ cả ở đầu vào lẫn đầu ra.

1.6. Container nối chúng lại thế nào

Laravel có zero-configuration resolution (tự dựng object mà không cần khai báo): nếu class chỉ phụ thuộc vào các class cụ thể khác, container tự new hết cây phụ thuộc. Vì vậy HomeFeed, PricedVariants, CategoryTree không cần đăng ký gì cả.

// Livewire: type-hint vào render() là container tự inject
public function render(CategoryBrowseCatalog $catalog, CategoryTree $tree) { ... }

Chỉ đăng ký khi có lý do đo được. Trong project đúng một binding:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    // Bảng giá đọc từ config và được hỏi vài lần mỗi lần render (danh sách band,
    // kiểm tra band đang chọn, trần của ô nhập). singleton = đọc + map config
    // đúng 1 lần mỗi request thay vì mỗi lần gọi.
    $this->app->singleton(PriceBands::class);
}

Đừng đăng ký binding cho vui. Một $this->app->bind(Foo::class, Foo::class) là dòng code không làm gì cả ngoài việc bắt người đọc phải đi kiểm tra. Chỉ bind khi: (a) cần singleton vì object đắt, (b) bind interface → implementation, (c) constructor cần tham số container không tự đoán được.

2. Lợi ích

Lợi íchCụ thể là gì
Test nhanh và nhiềuLogic giá nằm trong Support ⇒ EffectivePriceTest chạy hàng trăm vector giá trong mili-giây, không cần Postgres. Nếu logic đó nằm trong Service thì mỗi vector là một lần dựng database.
Một luật, một chỗEffectivePrice là nơi duy nhất tính tiền. Docblock ghi rõ "No arithmetic happens here" ở PricedVariants — service đi lấy dữ liệu, không tự nhân chia.
Không thể tạo dữ liệu saiDeal::__construct private ⇒ không tồn tại Deal sai kiểu trong hệ thống. Đây là "làm cho trạng thái sai không biểu diễn được", mạnh hơn validate.
Blade sạchView nhận mảng đã format, không gọi $product->variants (tránh N+1 ẩn trong template) và không biết tên cột.
Đổi schema không vỡ giao diệnĐổi tên cột DB chỉ sửa Model + mapper. Blade và component không đụng tới.
Đọc code nhanh hơnNhìn tên thư mục biết ngay class có đắt hay không. Thấy App\Support\ ⇒ gọi thoải mái trong vòng lặp. Thấy App\Services\ ⇒ dừng lại, kiểm tra có query trong loop không.

3. Tác hại / hạn chế

3.1. Nhiều file hơn, tầng gián tiếp hơn

Để hiểu "giá hiện lên màn hình từ đâu ra", phải mở 4 file: ProductDetailPricedVariantsEffectivePriceBuyBoxMoney. Với người mới vào team đây là chi phí thật. Với hệ thống nhỏ (CRUD 10 màn hình) thì chia tầng kiểu này là over-engineering rõ ràng.

3.2. Lằn ranh dễ rò rỉ — và project này rò một chỗ

Nói thẳng: app/Support/PopularityScore.php vi phạm chính quy ước của nó.

// app/Support/PopularityScore.php — nằm trong Support nhưng có query DB
public static function measuredAcrossCatalogue(): self
{
    return new self((float) (Product::query()
        ->active()
        ->where('rating_count', '>', 0)
        ->avg('rating_avg') ?? 0.0));      // ← I/O trong Support
}

Class này vừa chứa công thức (thuần) vừa có một named constructor đi đo dữ liệu thật (không thuần). Kết quả: không viết được unit test cho công thức mà không đụng DB, trừ khi dùng constructor còn lại. Cách sửa đúng quy ước: tách hàm measuredAcrossCatalogue() sang Service (hoặc gọi thẳng trong Command), để lại Support đúng phần công thức.

Trường hợp mờ thứ hai: CategoryBrowseSort::apply(Builder $query) — enum trong Support nhận vào Query Builder. Nó không chạy query (chỉ gắn orderBy), nên về kỹ thuật vẫn thuần. Nhưng nó khiến Support phụ thuộc Eloquent. Đây là đánh đổi có ý thức: gom "thứ tự sắp xếp" vào một chỗ đáng giá hơn là giữ Support tuyệt đối sạch.

3.3. Đặt tên trùng gây nhầm

Project có cả hai: App\Models\Deal (bảng deals) và App\Support\Deal (VO mô tả mức giảm giá). Cùng tên, khác nghĩa, khác namespace. Mỗi file phải xem use ở đầu mới biết đang nói cái nào. Bài học: VO nên đặt tên theo khái niệm nghiệp vụ, tránh trùng tên bảng — ví dụ DiscountRule thì không ai nhầm.

3.4. Bẫy Livewire: VO không sống qua request được

Livewire serialize public property vào snapshot rồi hydrate lại ở request sau. Một VO đặt vào public $x sẽ hoặc lỗi, hoặc trở về thành mảng — mất hết method. Trong ProductDetail, $selections là mảng chuỗi trần, còn ChosenVariant được dựng lại trong mỗi render(). Đó là lý do, không phải sơ ý.

4. Trade-off

4.1. Cây quyết định: viết code này ở đâu?

flowchart TD
  A["Có một đoạn logic mới"] --> B{"Chỉ dùng ở đúng 1 chỗ
và dưới ~10 dòng?"} B -->|"Đúng"| B1["Để nguyên tại chỗ
(private method).
Tách khi có chỗ thứ 2 dùng"] B -->|"Không"| C{"Có chạm DB / cache /
HTTP / queue không?"} C -->|"Có"| D{"Là truy vấn của
đúng 1 model?"} D -->|"Đúng"| D1["Query scope
trên Model"] D -->|"Không"| D2["app/Services"] C -->|"Không"| E{"Nó mô tả một 'thứ'
hay một 'phép tính'?"} E -->|"Một thứ"| F{"Tập giá trị
đóng và hữu hạn?"} F -->|"Đúng"| F1["Enum trong Support"] F -->|"Không"| F2["Value Object / DTO
trong Support"] E -->|"Phép tính"| G["Class static
trong Support"] style D2 fill:#3b2f16,stroke:#c99a2e,color:#f4e7c3 style F1 fill:#16302b,stroke:#3fae8f,color:#d3f2e8 style F2 fill:#16302b,stroke:#3fae8f,color:#d3f2e8 style G fill:#16302b,stroke:#3fae8f,color:#d3f2e8

4.2. Support/Service so với các lựa chọn khác

Phương ánĐượcMấtChọn khi
Nhét hết vào Model (fat model)Ít file, dễ tìmModel 2000 dòng, không test được phần logic mà không đụng DBApp nhỏ, logic bám sát một bảng
Nhét vào Controller / Livewire componentNhanh nhất lúc viếtKhông tái dùng được; test phải qua HTTP; component có 6 lý do để đổiPrototype, màn hình dùng một lần
Service + Support (project này)Test tách bạch; luật một chỗNhiều file; người mới phải học quy ướcNghiệp vụ có luật thật (giá, tồn kho, quyền)
Action (1 class = 1 việc, handle())Rất dễ tìm; hợp với ghi dữ liệuNổ ra hàng trăm class; nhiều Action chia sẻ state khóNhiều thao tác ghi khác nhau (đặt hàng, huỷ, hoàn tiền)
Repository (bọc Eloquent sau interface)Đổi được nguồn dữ liệuBọc thêm một lớp lên thứ vốn đã là abstraction; mất scope, eager loadingThật sự có 2 nguồn dữ liệu. Chỉ để "test dễ hơn" thì không đáng

Câu này hay bị hỏi bẫy: "Sao không dùng Repository pattern?" Trả lời thẳng: Eloquent đã là tầng abstraction trên database. Bọc thêm một interface với đúng một implementation là abstraction đầu cơ — trong khi phải trả giá bằng việc mất scope, mất eager loading, mất pagination. Chỉ đáng khi có thật hai nguồn dữ liệu (ví dụ đọc từ Elasticsearch, ghi vào Postgres).

4.3. Ngưỡng "khi nào mới tách"

5. Khó khăn khi vận hành thực tế

Vấn đềBiểu hiện lúc 3h sángCách giảm đau
Quy ước trôiSau 6 tháng, Support có class gọi Http::get(). Không ai nhớ luật.Ghi luật vào CLAUDE.md/CONTRIBUTING.md bằng một câu. Thêm rule PHPStan/Deptrac chặn App\Support dùng Illuminate\Support\Facades\*.
Service phình toHomeFeed 900 dòng, mỗi lần sửa một shelf lại sợ vỡ shelf khác.Ngưỡng: quá ~7 public method thì tách theo trục nghiệp vụ, không tách theo kỹ thuật.
Cache lệch tầngSửa công thức giá trong Support, deploy xong giá vẫn cũ vì Service cache 1 tiếng.Nhúng version vào key (project này dùng price:v1:{id}:{pricing_version}) — sửa công thức thì đổi v1, không cần đi xoá cache tay.
Debug xuyên tầngGiá sai. Sai ở query, ở cache, ở công thức, hay ở format?Ưu điểm của tách tầng: chạy đúng 1 unit test với input nghi ngờ là loại được ngay công thức. Còn lại chỉ 2 khả năng.
OnboardingNgười mới hỏi "class này để đâu" mỗi ngày.Docblock đầu class nói vì sao nó tồn tại, không nói nó làm gì (code đã nói rồi). Project này làm tốt chỗ đó.
Support gọi Support gọi SupportSửa Money::format làm gãy 12 test không liên quan.Chấp nhận được nếu là lá (leaf) như Money. Cấm Support gọi ngược Service.

Rule chặn tự động (đáng làm nhất)

# phpstan.neon — chặn Support chạm facade I/O
services:
  -
    class: PHPStan\Rules\...   # hoặc dùng deptrac cho gọn

Gọn hơn: dùng Deptrac/PHPStan trong CI với một layer rule: layer Support không được depend vào layer Services, Models, hay Illuminate\Support\Facades. Một dòng config, và quy ước tự bảo vệ mãi mãi — thay vì phụ thuộc vào việc người review có nhớ hay không.

6. Case thực tế và cách xử lý

Case 1 — "Chip lọc giá nói một đằng, kết quả một nẻo"

Tình huống: QA báo: chọn band "$25 – $50", URL ra ?min=25&max=50, nhưng bấm sang trang 2 thì mất filter, còn chip vẫn hiện "$25 – $50".

Nguyên nhân điển hình khi không có VO:

flowchart TD
  U["Input: min=25, max=50"]
  U --> A["Chip: tự format từ property component"]
  U --> B["Link phân trang: tự ghép query string"]
  U --> C["Query: tự parse rồi where"]
  A -.->|"lệch"| X["3 nguồn sự thật
→ sớm muộn cũng lệch"] B -.-> X C -.-> X style X fill:#3b1616,stroke:#c94e4e,color:#f6d4d4

Cách xử lý theo từng bước:

  1. Tạo VO PriceRange giữ đúng minCents/maxCents.
  2. Chuyển cả 3 chỗ sang đọc từ VO: label() cho chip, toQuery() cho link phân trang, và VO đưa thẳng vào builder.
  3. Đưa luật hợp lệ vào PriceRange::rules(), dùng chung cho cả URL guard lẫn form. Hai đường vào, một bộ luật.
  4. Quyết định quan trọng: input sai thì từ chối, không kẹp về biên (clamp). Vì kẹp sẽ trả về tập kết quả khác với những gì URL đang nói — người dùng share link đó cho người khác sẽ thấy kết quả không khớp.
  5. Test: PriceBandsTest kiểm tra các band không hở khoảng và không nằm ngoài dải giá thật của catalogue.

Sau khi sửa, sơ đồ trở thành hình chiếu một chiều — đúng như mục 1.4 ở trên.

Case 2 — "Giá trên trang chi tiết khác giá trên card"

Tình huống: Card ở trang chủ hiện $79 (-20%), vào trang chi tiết chọn đúng màu/size đó thì thành $85. Khách chụp màn hình gửi CSKH.

Chẩn đoán:

sequenceDiagram
  participant D as Dev
  participant U as Unit test
  participant S as Service
  participant DB as Postgres

  D->>U: Chạy EffectivePriceTest với đúng bộ số đó
  U-->>D: PASS → công thức KHÔNG sai
  Note over D: Loại được 1 trong 3 khả năng ngay lập tức
  D->>S: Card lấy giá từ đâu?
  S-->>D: cheapestForProducts() = giá RẺ NHẤT trong các variant
  D->>DB: Variant khách chọn có phải variant rẻ nhất không?
  DB-->>D: Không — nó đắt hơn
  Note over D: Không phải bug. Là "from price"
nhưng nhãn thiếu chữ "Từ"

Xử lý:

  1. Vì Deal và Flash sale được gán theo từng variant (ADR-0007), một sản phẩm nhiều variant không có một mức giảm giá duy nhất. Card buộc phải quote giá rẻ nhất.
  2. Sửa ở tầng view-model, không sửa công thức: ProductCard::from() thêm tiền tố "Từ" khi sản phẩm có nhiều hơn 1 mức giá. Sửa một chỗ, cả trang chủ lẫn trang danh mục cùng đổi.
  3. BuyBox::price() đã xử lý đúng: khi chưa chọn đủ thuộc tính thì hiện khoảng giá không có badge, chọn đủ rồi mới hiện giá và badge của đúng variant đó. Docblock ghi rõ: mang badge chung qua cả dải variant "chính là cái drift đã làm hỏng trang cũ".

Bài học rút ra để kể trong phỏng vấn: tách tầng không làm bug biến mất, nhưng làm việc khoanh vùng bug nhanh hơn hẳn. Ở đây, một unit test chạy 5ms đã loại bỏ được toàn bộ nghi ngờ về công thức trước khi phải mở database ra soi.

Case 3 — "Thêm một trang mới, phải copy 200 dòng"

Tình huống: Cần thêm trang "Sản phẩm của thương hiệu X", hiển thị lưới sản phẩm giống trang danh mục.

Vì đã tách tầng, việc cần làm rất ít:

Nếu logic nằm rải trong CategoryBrowse component thì bước này là copy-paste 200 dòng — và từ đó trở đi mọi bug phải sửa hai lần. Đây chính là lợi ích thật của tách tầng, và nó chỉ hiện ra ở màn hình thứ hai, không phải màn hình đầu tiên.

7. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
"Laravel có chuẩn nào cho app/Services không?" Không. Framework chỉ sinh Http, Models, Providers. Services/Support là quy ước team. Cái quan trọng là lằn ranh nhất quán, không phải tên.
"Phân biệt Value Object và Entity?" Entity định danh bằng idProduct đổi tên vẫn là sản phẩm đó. VO định danh bằng giá trịPriceRange(25, 50) đổi min thì thành khoảng khác hẳn. VO bất biến, Entity thì không.
"VO khác DTO ở đâu?" DTO chỉ mang dữ liệu qua tầng, không luật. VO có luật và tự bảo vệ tính hợp lệ. Thực tế hai cái hay lai nhau; điều bắt buộc chung là bất biến.
"Vì sao Money chỉ có hàm static? Sao không là VO?" Vì ở project này nó chỉ làm format, không mang giá trị tiền. Số tiền được lưu là int cents khắp nơi. Nếu cần cộng/trừ tiền hoặc đa tiền tệ thì phải nâng thành VO thật (Money(amount, currency)) — lúc đó cộng hai loại tiền khác nhau mới ném exception được.
"Vì sao lưu tiền bằng int cents?" Float làm tròn sai (0.1 + 0.2 !== 0.3). Int cents thì phép tính chính xác tuyệt đối; chỉ làm tròn đúng một lần ở cuối — đúng như EffectivePrice làm (deal xong rồi mới tới flash sale, chỉ round ở cents cuối).
"Service nên stateless hay stateful?" Mặc định stateless để dùng chung an toàn. Ngoại lệ hợp lý: builder-style như CategoryBrowseCatalog (forCategory()->withPrice()->paginate()) — trả static nên chuỗi gọi đọc được, nhưng phải cẩn thận không đăng ký nó làm singleton.
"Khi nào đăng ký singleton?" Khi dựng object đắt và nó stateless — như PriceBands (đọc + map config, bị hỏi nhiều lần mỗi render). Không bao giờ singleton một object có giữ state theo request.
"Sao không dùng Repository?" Eloquent đã là abstraction trên DB. Bọc thêm interface với một implementation là abstraction đầu cơ, và mất scope + eager loading. Chỉ làm khi thật sự có hai nguồn dữ liệu.
"Value Object có nhét vào Livewire public property được không?" Không nên. Livewire serialize property vào snapshot rồi hydrate lại; VO sẽ mất method hoặc lỗi. Giữ property là kiểu nguyên thuỷ, dựng lại VO trong render() — đúng cách ProductDetail làm với ChosenVariant.
"Làm sao ép team không phá quy ước?" Không dựa vào review. Thêm rule Deptrac/PHPStan trong CI: layer Support cấm depend vào Services, Models, facade. Một dòng config, hiệu lực vĩnh viễn.
"Project của bạn có chỗ nào phá quy ước không?" Có — và nên nói thật. PopularityScore nằm trong Support nhưng có named constructor đi query DB. Hướng sửa: đẩy phần đo dữ liệu sang Service/Command, giữ Support đúng phần công thức.
"Support có được gọi Service không?" Không, chiều phụ thuộc phải một chiều: Service → Support. Nếu Support cần dữ liệu, hãy truyền vào làm tham số. EffectivePrice::compute($listCents, $deal, $flashBps) là ví dụ: nó không đi tìm deal, deal được đưa tới.

Tóm tắt một câu

Service đi lấy dữ liệu (có I/O, inject qua container). Support biến dữ liệu thành thứ đúng hình dạng (thuần, bất biến, new thẳng). Value Object là Support có luật và định danh theo giá trị. DTO là Support chỉ có field. Chiều phụ thuộc chỉ đi một hướng: Service → Support, không bao giờ ngược lại.