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/Serviceshayapp/Support. Framework chỉ sinh sẵnapp/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) |
|---|---|
| Service | Class 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. |
| Support | Class 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:
- Support test được bằng
PHPUnit\Framework\TestCasetrần — không boot Laravel, không cần PostgreSQL, chạy mất vài mili-giây. - Service muốn test thì phải có database (hoặc mock cả đống), nên rơi vào
tests/Feature, chạy chậm hơn hàng chục lần.
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\TestCasechứ không phảiTests\TestCasechính là bằng chứng rằngEffectivePricelà logic thuần. Nếu một ngày nó cầnCache::hayDB::, 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/Services | app/Support |
|---|---|---|
| Chạm DB / cache / HTTP | Có — đó là lý do nó tồn tại | Không |
| Lấy instance bằng cách nào | Container inject (__construct / tham số render()) | new thẳng, hoặc static:: |
| Giữ state | Có thể (builder, cache cục bộ trong request) | Bất biến, không giữ state |
| Đặt tên | Danh từ chỉ việc: HomeFeed, PricedVariants, CategoryTree | Danh từ chỉ thứ: Money, PriceRange, StockBand |
| Test ở đâu | tests/Feature (cần Postgres) | tests/Unit (không cần gì) |
| Thay thế được không | Có — mock/fake khi test tầng trên | Khô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ệu | Nhiề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:
- Bất biến — tạo xong không sửa (
readonly). - So sánh theo giá trị — hai instance cùng field là một, không có id để phân biệt.
- 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ònnew 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.
PricedVariantcóonSale()— 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: có 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ầnsingletonvì 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 ích | Cụ thể là gì |
|---|---|
| Test nhanh và nhiều | Logic 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 sai | Deal::__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ạch | View 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ơn | Nhì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:
ProductDetail → PricedVariants → EffectivePrice → BuyBox → Money.
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 | Được | Mất | Chọn khi |
|---|---|---|---|
| Nhét hết vào Model (fat model) | Ít file, dễ tìm | Model 2000 dòng, không test được phần logic mà không đụng DB | App nhỏ, logic bám sát một bảng |
| Nhét vào Controller / Livewire component | Nhanh nhất lúc viết | Không tái dùng được; test phải qua HTTP; component có 6 lý do để đổi | Prototype, 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 ước | Nghiệ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ệu | Nổ 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ệu | Bọc thêm một lớp lên thứ vốn đã là abstraction; mất scope, eager loading | Thậ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"
- Lần 1 viết inline. Lần 2 ghi chú lại. Lần 3 mới tách ra Support.
- Ngoại lệ tách ngay từ lần 1: logic tiền bạc, quyền, hoặc bất kỳ thứ gì cần test bằng bảng dữ liệu. Ba loại này sai là mất tiền hoặc lộ dữ liệu, và test bằng bảng thì bắt buộc phải tách khỏi I/O.
5. Khó khăn khi vận hành thực tế
| Vấn đề | Biểu hiện lúc 3h sáng | Cách giảm đau |
|---|---|---|
| Quy ước trôi | Sau 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 to | HomeFeed 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ầng | Sử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ầng | Giá 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. |
| Onboarding | Ngườ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 Support | Sử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:
- Tạo VO
PriceRangegiữ đúngminCents/maxCents. - 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. - Đư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. - 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.
- Test:
PriceBandsTestkiể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ý:
- 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.
- 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. 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:
- Card: dùng lại
ProductCard::from()— không viết lại gì. - Sắp xếp: dùng lại enum
CategoryBrowseSort(có thể cần đổi tên cho trung tính hơn, ví dụProductSort). - Lọc giá: dùng lại
PriceRange+PriceBands. - Giá: dùng lại
PricedVariants. - Chỉ cần viết mới: một Service lấy sản phẩm theo brand, và một Livewire component.
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ỏi | Trả 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 id — Product đổ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.