Xây codebase dùng chung cho team Laravel
CV ghi một dòng: "Xây dựng codebase để team có thể tái sử dụng, kiến trúc thư mục code (modular, thêm lớp service giữa controller và model)". Interviewer sẽ hỏi dồn vào đúng dòng đó, và câu hỏi khó nhất không phải "anh chia thư mục thế nào" mà là "làm sao anh biết team thật sự dùng lại, chứ không phải copy-paste rồi sửa?" và "chỗ nào anh đã trừu tượng hoá quá sớm?".
Bài này đi từ cấu trúc thư mục → cách ép tuân thủ bằng máy → chỗ nó gãy. Bối cảnh xuyên suốt: LMS micro-learning, Laravel 12 + Livewire, một repo monolith, team 4 – 7 developer (có fresher), release 2 – 3 lần/tuần.
Thuật ngữ dùng trong bài
| Từ | Nghĩa |
|---|---|
| modular monolith (monolith chia khối) | Vẫn một app, một database, một lần deploy — nhưng code chia thành các khối có ranh giới rõ, khối này không được thò tay vào ruột khối kia |
| module boundary (ranh giới module) | Quy ước "module A chỉ được gọi module B qua cửa trước". Cửa trước ở đây là Contracts\ (interface) và Data\ (DTO) |
| service layer (lớp service) | Lớp đặt giữa controller và model, giữ nghiệp vụ. Controller chỉ nhận request và trả response |
| action class | Service nhưng chỉ có một việc: một class một method handle(). Ví dụ EnrollStudent |
| DTO (data transfer object) | Object chỉ chứa dữ liệu, không có logic. Dùng để truyền dữ liệu qua ranh giới thay vì đưa nguyên Eloquent model |
| contract | Interface. Bên gọi phụ thuộc vào interface, bên làm thật được thay mà bên gọi không phải sửa |
| service container binding | Khai báo với Laravel: "ai xin interface X thì đưa class Y". Sau đó chỉ cần type-hint interface ở constructor |
| macro | Gắn thêm method vào class có sẵn của Laravel lúc chạy (Str::macro, Builder::macro) |
| package auto-discovery | Composer package tự khai service provider trong extra.laravel.providers; Laravel tự đăng ký, người dùng không phải khai tay |
| path repository | Kiểu repository của Composer trỏ vào thư mục trên đĩa. Dùng để phát triển package nội bộ mà chưa cần đẩy lên server |
| shared kernel (phần lõi dùng chung) | Model/khái niệm mà mọi module đều phải biết (điển hình là User). Cố tình để ở lõi cho ai cũng import, thay vì giấu sau Contracts\ |
| architecture test (test kiến trúc) | Test không kiểm tra nghiệp vụ, chỉ kiểm tra hình dạng code: ai được import ai, class nào phải nằm ở đâu |
| rule of three (luật lần thứ ba) | Chỉ gom thành code dùng chung khi nhu cầu xuất hiện lần thứ 3. Hai lần đầu cứ để trùng |
| strict model | Model::shouldBeStrict() — bật 3 chốt cùng lúc: cấm lazy load, cấm đọc cột chưa select, cấm mass-assign cột lạ |
| ADR (architecture decision record) | File markdown ngắn ghi: bối cảnh — quyết định — hệ quả. Để 6 tháng sau không ai hỏi lại "sao hồi đó làm thế" |
Môi trường kiểm chứng. Mọi output trong bài chạy thật trên Laravel 12.64.0 + PHP 8.3.33 + PHPUnit 11.5.56 + Composer (container
laravel-app). Lab dựng ở/tmp/labbên trong container: copy code project, symlinkvendor/, database SQLite riêng — không đụngchiennn_commerce_postgresvà không đụng source project thật. Số đo thời gian chạy trên máy dev qua Docker — đọc tỉ lệ, đừng đọc số tuyệt đối.
Modular monolith giải quyết bài toán gì (so với chỉ thêm Service layer)
Câu hỏi đầu tiên interviewer hay hỏi ngược lại: "Laravel đã có sẵn
app/Models, app/Http/Controllers; anh thêm app/Services là
xong rồi, tách module để làm gì cho mệt?". Trả lời đúng phải thấy được: hai thứ đó
cắt codebase theo hai chiều khác nhau, không thay thế nhau.
- Service layer cắt ngang — theo vai trò kỹ thuật: controller / service / model. Nó trả lời câu "logic này để ở tầng nào".
- Module cắt dọc — theo mảng nghiệp vụ: Learning / Billing / Reporting. Nó trả lời câu "code này của ai, và ai được phép gọi".
Thêm Service layer mà vẫn để cấu trúc phẳng thì bạn đã trả lời câu thứ nhất và bỏ trống câu thứ hai. Hậu quả không thấy ngay lúc 20 file, nó hiện ra ở khoảng 100 file trở đi.
Contracts\; đường đỏ đi
thẳng vào Domain\ của module khác bị architecture test chặn ngay ở CI (mục 1.6).Bốn bài toán Service layer một mình không giải được
| Bài toán | Phẳng + Service layer | Có module |
|---|---|---|
| Không ai biết "được gọi ai" | InvoiceService import thẳng Enrollment model — hợp lệ, không ai cản. Đồ thị phụ thuộc dày lên âm thầm |
Import chéo ruột module là test đỏ, không phải comment review. Luật do máy giữ, không do trí nhớ lead |
| Một tính năng nằm rải 6 thư mục | Thêm "điểm danh": sửa Models/, Http/Controllers/, Services/, Requests/, views/, migrations/ — 6 chỗ ở 6 cây khác nhau |
Một thư mục modules/Attendance/. Đọc, bàn giao, hoặc xoá cả tính năng đều gọn |
| Thư mục dùng chung thành vô chủ | app/Services/ 80 file, 7 người cùng sửa, CODEOWNERS không chỉ được ai chịu trách nhiệm phần nào |
Mỗi module một chủ trong CODEOWNERS; review đúng người, conflict giảm |
| Không có đường ra | Muốn tách "Reporting" ra service riêng: phải gỡ tay từng import, không biết bao giờ xong | Module đã chỉ nói chuyện qua Contracts\ → đổi implementation thành HTTP client là xong về mặt code (dữ liệu vẫn là chuyện khác) |
Điểm cần nói rõ khi phỏng vấn: module không thay thế service layer. Service layer
vẫn nằm nguyên bên trong mỗi module (modules/Learning/Services/). Module chỉ thêm đúng
một thứ mà cấu trúc phẳng không có: một ranh giới có tên, có chủ, và ép được bằng máy.
Cái giá phải trả, và khi nào đừng tách
| Chi phí thật của việc tách module | Biểu hiện |
|---|---|
| Phải quyết định ranh giới sớm, lúc còn chưa hiểu domain | Chia sai → 3 tháng sau phải gộp/tách lại, sửa hàng loạt namespace |
| Việc nào cũng đụng 2 module thì tệ hơn lúc chưa tách | Mỗi MR phải sửa cả contract lẫn 2 implementation |
| Query chéo module trở nên phiền | Báo cáo cần JOIN 3 bảng của 3 module — phải chọn: cho phép đọc chéo, hay chịu N truy vấn (xem mục 1.9) |
| Người mới hỏi "cái này để module nào" | Tốn một buổi bàn cho một class 30 dòng |
Khi nào KHÔNG tách module. App dưới ~30 – 40 class service, team 1 – 3 người, chỉ một mảng nghiệp vụ, hoặc domain còn đang thay đổi mỗi tuần → cứ để cấu trúc Laravel mặc định + service layer. Ngưỡng nên tách là khi một trong ba dấu hiệu xuất hiện: (1) hai người trở lên thường xuyên sửa đè lên nhau trong cùng một thư mục; (2) không ai dám xoá một file vì không biết ai đang dùng; (3) bắt đầu có nhu cầu "phần này giao cho team khác/tách ra sau này". Tách vì "kiến trúc cho đẹp" mà chưa có dấu hiệu nào là trừu tượng hoá quá sớm — đúng cái lỗi mục 3 nói.
Câu trả lời một câu: "Service layer giải quyết chuyện logic nằm ở tầng nào; module giải quyết chuyện logic thuộc về ai và ai được gọi nó. Tôi cần cái thứ hai vì team 4 – 7 người có fresher, và vì tôi muốn luật ranh giới do CI giữ chứ không do tôi nhớ lúc review."
1. Nó là gì / chạy thế nào
1.1. "Codebase dùng chung" gồm ba tầng, đừng gộp làm một
Câu hỏi "xây codebase dùng chung thế nào" thật ra là ba câu hỏi khác nhau, chi phí khác hẳn nhau:
flowchart TB T1["Tầng 1 — trong một module
trait, scope, Blade component, helper
Chi phí đổi: sửa 1 file, review 5 phút"] T2["Tầng 2 — giữa các module, cùng repo
Contracts + DTO + service container
Chi phí đổi: 1 MR, CI chạy lại, cả team pull"] T3["Tầng 3 — giữa nhiều repo
package Composer nội bộ + version
Chi phí đổi: tag version, n repo bump, n lần deploy"] T1 --> T2 --> T3 N["Càng xuống dưới, đổi càng đắt
→ chỉ xuống khi đã đau thật"] T3 -.-> N
Sai lầm hay gặp của lead mới: nhảy thẳng lên tầng 3 ("tách package cho pro") khi cả công ty mới
có một repo. Lúc đó mỗi lần sửa một dòng helper phải: commit package → tag → bump version
ở app → chạy composer update → deploy. Đổi lấy được gì? Không gì cả, vì chỉ
có một người dùng nó.
1.2. Cấu trúc thư mục: module là một thư mục có service provider riêng
Không cần package bên ngoài. Laravel đã có sẵn đủ thứ: PSR-4 của Composer + một service provider cho mỗi module.
app/ # code khung: Providers, Exceptions, base class
modules/
Learning/
Contracts/ # CỬA TRƯỚC — module khác chỉ được import từ đây
EnrollmentReader.php
Data/ # CỬA TRƯỚC — DTO đi qua ranh giới
EnrollmentData.php
Domain/ # ruột: Eloquent model, business object
Services/ # nghiệp vụ nhiều bước
Http/ # Controller, Livewire component, FormRequest
Providers/LearningServiceProvider.php
routes/web.php
resources/views/
database/migrations/
tests/
Billing/
Reporting/
Service provider của module chỉ có đúng ngần này — không có gì thông minh hơn:
<?php
namespace Modules\Learning\Providers;
use Illuminate\Support\ServiceProvider;
class LearningServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadRoutesFrom(__DIR__."/../routes/web.php");
$this->loadViewsFrom(__DIR__."/../resources/views", "learning");
$this->loadMigrationsFrom(__DIR__."/../database/migrations");
}
}
Hai chỗ khai báo, làm một lần rồi thôi:
// composer.json
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "modules/"
}
}
// bootstrap/providers.php (Laravel 11+)
return [
App\Providers\AppServiceProvider::class,
Modules\Learning\Providers\LearningServiceProvider::class,
];
Chạy thật trong lab — route, view namespace và migration của module đều được nhận:
$ composer dump-autoload
Generated optimized autoload files containing 8106 classes
$ php artisan route:list --path=learning
GET|HEAD learning/ping learning.ping › modules/Learning/routes/web.php
$ php artisan tinker --execute="echo view('learning::ping')->render();"
pong from module
$ php artisan migrate:status | grep learning
2026_01_01_000000_learning_demo ......................... Pending
Đây là toàn bộ "framework module" mà team cần. Khoảng 15 dòng code. Câu hỏi interviewer hay gài: "sao không dùng
nwidart/laravel-modules?" — trả lời ở mục 4 (Trade-off): package đó thêm generator, cấu hình, cách đặt tên riêng và một lớp phải nâng cấp mỗi lần Laravel lên major. Đổi lại nó thay thế đúng 15 dòng trên.
1.3. Một request đi qua những tầng nào
sequenceDiagram autonumber participant U as Browser / Livewire participant C as Controller / Livewire component participant FR as FormRequest (validate) participant S as Service / Action participant M as Eloquent model participant EV as Event + Listener (queue) U->>C: POST /enroll C->>FR: rules() + authorize() FR-->>C: dữ liệu đã sạch C->>S: EnrollStudent::handle(EnrollmentData) S->>M: transaction: insert + update M-->>S: Enrollment S->>EV: StudentEnrolled (dispatch sau commit) S-->>C: EnrollmentData C-->>U: redirect / render EV-->>EV: gửi mail, cập nhật báo cáo — worker chạy
Quy ước ngắn gọn dán ở đầu CONTRIBUTING.md, và đây cũng là checklist review:
| Tầng | Được làm | Cấm |
|---|---|---|
| Controller / Livewire | Nhận input, gọi 1 service, trả view/redirect | Query, DB::, if nghiệp vụ, transaction |
| FormRequest | Validate, authorize | Ghi dữ liệu |
| Service / Action | Nghiệp vụ, transaction, dispatch event | Đụng request(), session(), auth() — nhận qua tham số |
| Model | Quan hệ, cast, scope, accessor | Gửi mail, gọi API ngoài, logic nhiều bước |
| Job / Listener | Việc chạy nền, gọi lại service | Chứa nghiệp vụ riêng — nghiệp vụ nằm ở service để chỗ khác gọi lại được |
Luật "service không được đụng request()" nghe khô khan nhưng chính nó làm code
dùng lại được: cùng một EnrollStudent gọi được từ controller web, từ Livewire,
từ artisan command import Excel, và từ test — không cần giả lập HTTP request.
1.4. Cái gì đáng đưa vào "core dùng chung", cái gì không
| Ứng viên | Nên? | Vì sao |
|---|---|---|
app/Models/BaseModel bật strict + cast chung | Nên | Một dòng ở một chỗ, ép cả team, không ai phải nhớ |
Contracts/ + container binding giữa module | Nên | Là ranh giới thật, test thay thế được bằng fake |
Trait HasUuid, BelongsToTenant | Nên | Hành vi thật sự giống nhau ở mọi bảng, ít khả năng phân hoá |
| Blade/Livewire component dùng chung (bảng, phân trang, modal) | Nên | Giao diện lệch nhau là lỗi người dùng thấy ngay |
| Exception + handler chung (mã lỗi, format JSON) | Nên | Client chỉ cần học một format |
BaseService có create/update/delete | Không | Mỗi nghiệp vụ "create" mỗi khác; sớm muộn thành class 900 dòng với 12 tham số $options |
BaseRepository bọc Eloquent | Không | Eloquent đã là lớp repository. Bọc thêm chỉ để "đổi ORM sau này" — việc chưa ai từng làm |
BaseController có sẵn $this->service | Không | Ràng mọi controller vào một hình dạng; kế thừa ngược chiều đọc code |
Helper format_money(), viet_date() | Tuỳ | Chỉ gom khi đã xuất hiện lần thứ 3 (rule of three) |
Câu chốt khi bị hỏi "team anh có base class không": "Có, nhưng chỉ base class ép ràng buộc (strict model, format lỗi, tenant scope), không có base class gom nghiệp vụ. Base nghiệp vụ là chỗ cả team đổ code vào rồi không ai dám xoá."
1.5. Ranh giới thật: Contracts + container binding
Module Billing cần biết một khoá học có bao nhiêu sinh viên đang học. Hai cách viết:
// CÁCH SAI — Billing thò tay vào ruột Learning
use Modules\Learning\Domain\Enrollment; // model của module khác
$count = Enrollment::where("course_id", $id)->where("status", "active")->count();
Hỏng ở chỗ: ngày Learning đổi cột status thành bảng riêng, Billing gãy mà người sửa
Learning không hề biết. Grep "ai đang dùng bảng này" thì ra 30 chỗ rải khắp repo.
// CÁCH ĐÚNG — Learning mở một cửa trước
namespace Modules\Learning\Contracts;
interface EnrollmentReader
{
public function countActive(int $courseId): int;
}
// Learning tự làm phần ruột
namespace Modules\Learning\Services;
class EloquentEnrollmentReader implements EnrollmentReader
{
public function countActive(int $courseId): int
{
return Enrollment::where("course_id", $courseId)->active()->count();
}
}
// LearningServiceProvider::register()
$this->app->bind(EnrollmentReader::class, EloquentEnrollmentReader::class);
// Billing chỉ type-hint interface, không biết Learning dùng bảng nào
namespace Modules\Billing\Domain;
class InvoiceBuilder
{
public function __construct(private EnrollmentReader $enrollments) {}
}
Được gì cụ thể: đổi cách lưu bên Learning → sửa một class; test InvoiceBuilder
→ nhét fake vào container, không cần seed bảng enrollment; và quan trọng nhất —
grep EnrollmentReader ra đúng danh sách người phụ thuộc.
Mất gì: thêm một file interface cho mỗi cửa. Nên chỉ mở cửa khi có module khác thật sự cần — không phải viết interface cho mọi service.
1.5.1. Cửa trước trả gì, nhận gì
Ví dụ ở trên trả về int nên chưa lộ vấn đề. Câu hỏi thật là khi cửa phải trả về cả
một bản ghi — lúc đó trả thẳng Eloquent model hay trả DTO? Chốt trước, giải thích sau:
| Vị trí | Trả về / nhận vào |
|---|---|
| Return | Scalar nếu đủ (int, bool, string) → readonly DTO khi nhiều field → không bao giờ là Eloquent model |
| Parameter | Scalar id, hoặc DTO do module sở hữu cửa định nghĩa. Gộp thành một command DTO khi > 3 tham số, hoặc có 2 tham số cùng kiểu cạnh nhau, hoặc có bool |
| Ngoại lệ duy nhất | User — shared kernel, để ở app/Models, ai cũng import (xem mục 1.9). Model khác thì không |
Vì sao không trả model: chữ ký hàm tự làm thủng ranh giới
Lý do mạnh nhất không phải "DTO sạch hơn", mà là chính dòng use trong file interface:
namespace Modules\Learning\Contracts;
use Modules\Learning\Domain\Enrollment; // <-- ngay dòng này
interface EnrollmentReader
{
public function find(int $id): Enrollment; // Billing buộc phải import Enrollment
}
Billing type-hint Enrollment ở chỗ nhận giá trị → architecture test ở mục 1.6
bắt ngay, vì use Modules\Learning\Domain\... xuất hiện trong file của Billing. Tức là
cửa trước mở ra đúng cái ruột mà nó định giấu. Cửa trả model là cửa không có cánh.
update(), delete()), quyền đi tiếp quan hệ
(lazy load → N+1 và phụ thuộc schema module khác), và quyền đọc cột (Learning
đổi cột thì Billing gãy, grep không ra).
Phải: DTO chỉ có đúng những field đã công bố, nên ba đường kia không tồn tại —
không phải vì có luật cấm, mà vì không có method nào để gọi.Viết đúng thì cửa không import Domain\ một dòng nào:
namespace Modules\Learning\Data;
final readonly class EnrollmentSummary
{
public function __construct(
public int $id,
public int $courseId,
public int $studentId,
public string $status,
) {}
}
// Contracts\EnrollmentReader — không có use Domain\...
public function countActive(int $courseId): int; // scalar đủ thì đừng đẻ DTO
/** @return list<EnrollmentSummary> */
public function summaries(array $courseIds): array;
Parameter: luật đối xứng, nhưng khác một chỗ
// SAI — Learning phải import model của Billing để khai interface của chính mình
public function enroll(Invoice $invoice, Student $student): void;
// SAI — nhận Builder/Collection/Request: lộ trạng thái query và tầng HTTP ra ngoài
public function search(Builder $query): array;
// SAI — array không type: Larastan mù, gõ sai key trả null âm thầm
public function enroll(array $payload): int;
// ĐÚNG — id thô, hoặc DTO do chính Learning định nghĩa
public function enroll(EnrollmentData $data): int; // trả id, KHÔNG trả model vừa tạo
Khác biệt so với return: DTO tham số vẫn thuộc module sở hữu cửa (Learning), không
phải bên gọi. Learning công bố hình dạng dữ liệu nó chịu nhận; Billing import
Modules\Learning\Data\EnrollmentData. Nếu để Billing tự định nghĩa DTO rồi ném sang thì
hướng phụ thuộc lộn ngược — Learning phải biết Billing.
Khi nào gộp tham số thành DTO:
// Bom nổ chậm: 5 tham số, 2 bool, gọi sai thứ tự vẫn chạy được
enroll(int $courseId, int $studentId, bool $trial, bool $notify, ?string $note)
// Gộp: named arguments + readonly, sai kiểu là IDE đỏ ngay
enroll(new EnrollmentData(courseId: 10, studentId: 3, trial: true))
Validate vẫn phải nằm trong module sở hữu. DTO có type không thay thế được validate nghiệp vụ (khoá học còn chỗ không, sinh viên có bị khoá không). Đây là trust boundary (ranh giới tin cậy — chỗ dữ liệu từ bên ngoài đi vào), không được bỏ vì "bên gọi chắc đã check rồi".
Khi DTO bí: đổi hình dạng cửa, đừng trả model
Màn hình báo cáo cần lọc + sắp xếp + phân trang theo cột của module khác thì DTO không cứu được — không lọc trong SQL được nữa (chính là bảng "Mất gì ở mức C" ở mục 1.9). Lối thoát không phải là trả model ra, mà là để cửa nhận tiêu chí thay vì trả dữ liệu:
// Learning giữ SQL trong ruột nó; Billing chỉ mô tả cái nó muốn
public function search(EnrollmentCriteria $criteria): PaginatedResult;
Chốt để trả lời phỏng vấn: "Cửa trả scalar nếu đủ, còn lại trả
readonlyDTO. Không trả Eloquent model, lý do không phải cho sạch mà vì chữ ký hàm buộc bên gọi import model của module khác — đúng thứ architecture test đang cấm. Tham số cũng vậy, và DTO tham số do bên sở hữu cửa định nghĩa để hướng phụ thuộc không lộn ngược. Ngoại lệ làUservì nó là shared kernel. Cần lọc/sắp xếp xuyên module thì đổi cửa thành nhận tiêu chí, chứ không mở ruột ra."
1.6. Ép tuân thủ bằng máy, không bằng lời nhắc trong review
Quy ước chỉ nằm trong tài liệu thì 3 tháng sau là tài liệu sai. Ba lớp chặn, chạy trong CI:
flowchart LR A["git push"] --> B["Pint
--test
format code"] B --> C["Larastan level 6
bắt type, null, method không tồn tại"] C --> D["Architecture test
ranh giới module"] D --> E["PHPUnit
nghiệp vụ"] E --> F["Merge được"] B -->|"fail"| X["pipeline đỏ
không ai phải nhắc trong review"] C -->|"fail"| X D -->|"fail"| X
Lớp thứ ba — architecture test — là thứ ít team làm nhất mà lại rẻ nhất. Nó là một test PHPUnit thuần, không boot app, chạy hết 9 ms:
<?php
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator as Dir;
use RecursiveIteratorIterator as Walk;
class ModuleBoundaryTest extends TestCase
{
/** Module chỉ được gọi module khác qua lớp công khai: Contracts\ hoặc Data\ */
public function test_modules_only_depend_on_public_layers(): void
{
$root = dirname(__DIR__, 2)."/modules";
$violations = [];
foreach (new Walk(new Dir($root, Dir::SKIP_DOTS)) as $file) {
if ($file->getExtension() !== "php") continue;
$self = explode("/", substr($file->getPathname(), strlen($root) + 1))[0];
preg_match_all("/^use\s+Modules\\\\(\w+)\\\\([\w\\\\]+);/m", file_get_contents($file), $m, PREG_SET_ORDER);
foreach ($m as [$line, $target, $rest]) {
if ($target === $self || preg_match("/^(Contracts|Data)\\\\/", $rest)) continue;
$violations[] = str_replace("$root/", "", $file->getPathname())." => ".trim($line);
}
}
$this->assertSame([], $violations, "Module gọi thẳng vào ruột module khác:\n".implode("\n", $violations));
}
}
Chạy thật với một vi phạm cố ý (Billing import model của Learning):
$ php vendor/bin/phpunit tests/Architecture/ModuleBoundaryTest.php
PHPUnit 11.5.56 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 00:00.017, Memory: 10.00 MB
1) Tests\Architecture\ModuleBoundaryTest::test_modules_only_depend_on_public_layers
Module gọi thẳng vào ruột module khác:
Billing/Domain/InvoiceBuilder.php => use Modules\Learning\Domain\Enrollment;
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
Sau khi đổi sang use Modules\Learning\Contracts\EnrollmentReader;:
$ php vendor/bin/phpunit tests/Architecture/ModuleBoundaryTest.php
. 1 / 1 (100%)
Time: 00:00.009, Memory: 10.00 MB
OK (1 test, 1 assertion)
27 dòng, không thêm dependency nào. Có package làm việc này đẹp hơn (
pestphp/pest-plugin-arch,deptrac) — đáng thêm khi số luật vượt quá 5 – 6 và bắt đầu cần whitelist ngoại lệ. Trước đó thì một file test là đủ, và nó không bao giờ vỡ khi Laravel lên version mới.
1.7. Base model: bật strict cho cả team bằng một dòng
Việc dùng chung có giá trị nhất không phải là class tiện ích, mà là ràng buộc bật một
lần cho toàn bộ code. Model::shouldBeStrict() bật ba chốt cùng lúc. Chạy thật:
// app/Providers/AppServiceProvider::boot()
Model::shouldBeStrict(! $this->app->isProduction());
$ php strict.php # Laravel 12.64.0, SQLite lab
1) Illuminate\Database\LazyLoadingViolationException: Attempted to lazy load [lessons]
on model [TmpCourse] but lazy loading is disabled.
2) Illuminate\Database\Eloquent\MissingAttributeException: The attribute [title] either
does not exist or was not retrieved for model [TmpCourse].
3) Illuminate\Database\Eloquent\MassAssignmentException: Add fillable property
[khong_co_trong_fillable] to allow mass assignment on [TmpCourse].
Ba lỗi này bắt được đúng ba thói quen hay gặp của dev mới: N+1, select() thiếu cột
rồi đọc bừa, và nhét thẳng $request->all() vào create().
Bẫy 1 — strict mode không bắt khi query trả về đúng 1 dòng. Đọc source Laravel 12 (
Eloquent/Builder.php, hàmhydrate):$model = $instance->newFromBuilder($item); if (count($items) > 1) { // <-- điều kiện ở đây $model->preventsLazyLoading = Model::preventsLazyLoading(); }Tức là lazy load trên kết quả một dòng không bị chặn (hợp lý: một dòng thì không có N+1). Hệ quả thực tế: dev test local với 1 bản ghi thấy "chạy ngon", lên staging có 3 bản ghi thì nổ
LazyLoadingViolationException. Lab xác nhận đúng như vậy: 1 course → im lặng; 2 course → ném exception.
Bẫy 2 — Laravel 12 có
automaticallyEagerLoadRelationships(), bật lên là strict mode câm. Chạy thật, bật cả hai cùng lúc:$ php autoload_eager.php OK - khong nem exception So query chay: 2 - select * from "tmp_courses" - select * from "tmp_lessons" where "tmp_lessons"."tmp_course_id" in (1, 2, 3)Laravel tự gom thành eager load nên không còn vi phạm để mà ném. Nghe thì hay, nhưng ở codebase dùng chung phải chọn một triết lý: hoặc để dev thấy lỗi và tự viết
with()(nhìn được truy vấn), hoặc để framework tự gom (êm hơn, nhưng dev không bao giờ học được và không kiểm soát được thứ tự truy vấn). Team tôi chọn cách 1 cho dev/staging vì đội có fresher — mục tiêu là dạy, không phải giấu.
1.8. Khi nào mới tách package Composer nội bộ
Tách khi repo thứ hai cần cùng một đoạn code (ví dụ: LMS Laravel và một dịch vụ
NestJS-đi-kèm-PHP-worker dùng chung cách ký JWT, cùng format mã sinh viên). Lúc phát triển, dùng
path repository để không phải đẩy lên server:
// composer.json của app
"repositories": [
{ "type": "path", "url": "packages/support" }
],
"require": { "onschool/support": "@dev" }
// packages/support/composer.json — auto-discovery
{
"name": "onschool/support",
"autoload": { "psr-4": { "OnSchool\\Support\\": "src/" } },
"extra": {
"laravel": {
"providers": ["OnSchool\\Support\\SupportServiceProvider"]
}
}
}
$ composer update onschool/support
- Installing onschool/support (dev-main bdd26d3): Symlinking from packages/support
$ php artisan package:discover
onschool/support ...................................... DONE
$ php artisan tinker --execute="echo Str::maSinhVien(42);"
SV000042
Bẫy 3 — CI chạy
composer install --no-scriptsthì package nội bộ chết âm thầm. Đúng lỗi tôi gặp trong lab: cài xong, class có, autoload có, nhưng service provider chưa được discover → macro không tồn tại →BadMethodCallException: Method Illuminate\Support\Str::maSinhVien does not existngay lúc chạy, không phải lúc build. Nếu pipeline dùng--no-scripts(nhiều team bật để tránh script lạ chạy trong CI) thì phải gọi tayphp artisan package:discovertrong Dockerfile, hoặc khai thẳng provider vàobootstrap/providers.php.
1.9. Model dùng chung (User) — tách module hay không, và Eloquent mất gì
Đây là câu hỏi đầu tiên mọi người vấp khi chia module, vì User là thứ module nào
cũng cần: Billing cần email để gửi hoá đơn, Learning cần biết ai đang học, Reporting cần tên để
in báo cáo. Câu hỏi đi kèm luôn là: "tách ra rồi thì có còn
$invoice->user->email nữa không?"
Trả lời ngắn: Eloquent không hề biết khái niệm "module". Nó phân giải quan hệ bằng tên class đầy đủ (FQN) và khoá ngoại — namespace nằm ở đâu không liên quan. Cái làm mất relationship không phải việc tách thư mục, mà là luật do chính mình đặt ra ở mục 1.5 (cấm import ruột module khác) cộng với architecture test ở mục 1.6 thi hành nó.
| Ranh giới | Do cái gì tạo ra | Có chặn relationship không |
|---|---|---|
| Kỹ thuật — Composer PSR-4, namespace | Composer | Không. belongsTo, with(), whereHas() chạy y như cũ |
Kỷ luật — "chỉ gọi qua Contracts\" | Quy ước + architecture test | Có. Và đây mới là chỗ phải cân nhắc |
Chạy thật: quan hệ xuyên module vẫn hoạt động
Lab khai báo Modules\Identity\Domain\User và Modules\Billing\Domain\Invoice
ở hai namespace khác nhau, rồi eager load:
// modules/Billing/Domain/Invoice.php
public function user(): BelongsTo
{
return $this->belongsTo(\Modules\Identity\Domain\User::class, 'user_id');
}
Invoice::with('user:id,name,email')->where('status', 'unpaid')->get();
### 1. belongsTo xuyên module (Billing -> Identity) + eager load
class Invoice : Modules\Billing\Domain\Invoice
class User : Modules\Identity\Domain\User
số query : 2
SQL: select * from "invoices" where "status" = ?
SQL: select "id", "name", "email" from "users" where "users"."id" in (1, 2, 3, 4, 5)
đọc được : u1@test.vn
Và whereHas — thứ đắt giá nhất — vẫn gom được về một
câu SQL, tức là lọc theo cột của User mà không phải bê dữ liệu về PHP:
### 2. whereHas + join xuyên module — lọc theo cột của User trong 1 câu SQL
số query : 1
SQL : select * from "invoices" where exists (select * from "users"
where "invoices"."user_id" = "users"."id" and "email" like ?)
kết quả : 1 hoá đơn (user_id=3)
Ba mức tách — chọn mức nào
| Mức | User nằm ở đâu | Module khác được làm gì | Relationship | Khi nào chọn |
|---|---|---|---|---|
| A. Shared kernel mặc định | app/Models/User.php | Import thoải mái | Giữ nguyên 100% | Monolith một database. Chọn cái này. |
| B. Module + whitelist | modules/Identity/Domain/User.php | Đọc model được; ghi phải qua Contract | Giữ nguyên | Đội đông, muốn kiểm soát chỗ ghi (khoá tài khoản, đổi email) |
| C. Tách cứng | modules/Identity/, cấm import | Chỉ UserReader::findMany(): array | Mất with, whereHas, join | Chỉ khi bảng users sắp nằm ở database/service khác |
Mức A không phải chọn cho lười — nó đúng lý thuyết. Thực thể mà mọi module đều
phải tham chiếu chính là định nghĩa của shared kernel (phần lõi chia sẻ có chủ ý).
Ép nó thành module thường là ép sai hình. Tiện lợi kèm theo: architecture test ở mục 1.6 chỉ quét
use Modules\..., nên App\Models\User tự động hợp lệ,
không phải viết thêm một dòng whitelist nào.
Cái đáng tách là hành vi, không phải bảng
Bệnh thật của User không phải "nằm sai thư mục" mà là phình: mỗi
module nhét thêm vài method, ba năm sau User.php 800 dòng, đụng vào là sợ. Cách chữa:
mỗi module giữ bảng mở rộng của riêng nó, quan hệ trỏ một chiều từ module vào
User.
User → class phình, thành chỗ cả team đụng vào cùng lúc.
Giai đoạn 2 (viền xanh): method quay về module dưới dạng bảng riêng; quan hệ chỉ
đi một chiều từ module vào User. User giữ đúng phần định danh
và không bao giờ phải sửa nữa.// modules/Billing/Domain/BillingProfile.php — bảng billing_profiles
class BillingProfile extends Model
{
public function user(): BelongsTo // Billing -> User: được phép
{
return $this->belongsTo(User::class, 'user_id');
}
}
Chú ý: User không có method billingProfile(). Billing
tự query từ phía nó. Nếu thật sự cần viết $user->billingProfile cho gọn, Laravel có
sẵn đường chính thống để module gắn quan hệ vào model của người khác mà không sửa file
User.php — doc yêu cầu luôn ghi rõ tên khoá:
// modules/Billing/Providers/BillingServiceProvider::boot()
User::resolveRelationUsing('billingProfile', fn (User $u) =>
$u->hasOne(BillingProfile::class, 'user_id', 'id')
);
### 4. resolveRelationUsing — module tự gắn quan hệ vào User, không sửa User.php
$u1->billingProfile->tax_code = MST-001
eager load được luôn : 2 query
Giá của
resolveRelationUsing: quan hệ chỉ tồn tại lúc chạy, nên IDE không gợi ý và Larastan không nhìn thấy → phải khai@property-readtrong PHPDoc củaUser, tức làUservẫn phải biết tên module. Mặc định nên để quan hệ một chiều (chỉBillingProfile->user()) cho dễ đọc; dùngresolveRelationUsingkhi viết package thật sự.
Nếu vẫn chọn mức C — giá phải trả, đo trong lab
Ở mức C, Billing không được biết class User nữa, nên phải tự làm lại việc mà
with() vẫn làm hộ: lấy danh sách id → gọi Contract → ghép dữ liệu vào. Chỉ
cần quên bước "gọi một lần cho cả danh sách" là N+1 quay lại ngay:
where id in (…), còn mức C nếu viết ẩu thì hỏi Contract từng
dòng một. Số trong hình là output thật của lab.// ĐÚNG — gọi Contract một lần cho cả danh sách rồi tự ghép
$invoices = Invoice::where('status', 'unpaid')->get();
$users = $userReader->findMany($invoices->pluck('user_id')->all());
$invoices->each(fn ($i) => $i->setAttribute('user_data', $users[$i->user_id] ?? null));
// SAI — hỏi từng dòng
$invoices->each(fn ($i) => $i->setAttribute('user_data', $userReader->find($i->user_id)));
### 3. Mức C (cấm import model) — thay eager load bằng 2 query + hydrate
ĐÚNG (findMany) : 2 query
SAI (find/dòng): 6 query <-- N+1 quay lại
thay whereHas : 2 query, phải bê 2 bước sang PHP
| Mất gì ở mức C | Hậu quả thật |
|---|---|
with('user') | Tự viết bước ghép dữ liệu; quên findMany → N+1 |
whereHas('user', ...) | Đau nhất. Không lọc theo cột của User trong SQL được nữa — phải lấy id từ Identity rồi whereIn. Danh sách 50 nghìn id thì query vỡ |
orderBy('users.name') + phân trang | Sắp xếp xuyên nguồn thì phải kéo hết về PHP → phân trang sai hoặc hết RAM |
join users trong báo cáo | Reporting sẽ tự đọc thẳng bảng users — ranh giới vẫn thủng, chỉ là thủng chỗ khác và không ai thấy |
Hai dòng giữa là lý do thật khiến mức C sập ở dự án có màn hình báo cáo. Khi nào mức C đáng?
Khi bảng users sắp nằm ở database khác — lúc đó join vốn đã
không còn, trả giá sớm là hợp lý.
Bốn cái bẫy phải biết trước khi di chuyển User
Bẫy 1 — polymorphic lưu nguyên FQN vào database. Đây là bẫy chết người nhất khi đổi namespace, vì hỏng ở dữ liệu chứ không phải ở code:
### 5. Polymorphic — FQN trong DB vs enforceMorphMap
CHƯA morph map, cột notable_type lưu:
Modules\Identity\Domain\User
SAU enforceMorphMap:
user
model chưa đăng ký -> ClassMorphViolationException:
No morph map defined for model [Modules\Billing\Domain\Invoice].
đọc ngược morph alias: Relation::getMorphedModel('user') = Modules\Identity\Domain\User
dữ liệu cũ còn FQN : Modules\Identity\Domain\User <-- phải UPDATE khi bật morph map
// AppServiceProvider::boot() — dùng enforceMorphMap, KHÔNG dùng morphMap
Relation::enforceMorphMap([
'user' => \Modules\Identity\Domain\User::class,
'invoice' => \Modules\Billing\Domain\Invoice::class,
]);
Khác nhau một chữ nhưng quan trọng: morphMap() gặp model chưa khai thì im lặng
ghi FQN vào database; enforceMorphMap() ném ClassMorphViolationException
ngay — hỏng lúc test, không hỏng lúc 3 giờ sáng. Dòng cuối của output là phần hay bị quên:
bật morph map không sửa dữ liệu cũ, phải có migration
UPDATE ... SET notable_type = 'user' WHERE notable_type = '...' cho mọi bảng morph
(notifications, activity_log, media, comments…).
Bẫy 2 — job đang nằm trong queue giữ FQN cũ. SerializesModels
không lưu cả model, nó lưu một ModelIdentifier — và trong đó có tên class:
### 6. Job trong queue giữ FQN của model — bẫy khi đổi namespace
O:36:"Modules\Billing\Jobs\SendInvoiceMail":1:{s:4:"user";
O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{
s:5:"class";s:28:"Modules\Identity\Domain\User";s:2:"id";i:1; ... }}
Deploy đổi namespace trong lúc queue còn hàng → worker bốc job cũ lên →
ClassNotFoundException, job rơi vào failed_jobs. Cách xử lý: để queue cạn
trước khi deploy, hoặc giữ một class rỗng ở namespace cũ class_alias() sang tên mới
trong một release, release sau mới xoá.
Bẫy 3 — config/auth.php trỏ thẳng tên class.
'model' => App\Models\User::class. Đổi chỗ mà quên sửa thì ở máy dev vẫn chạy (config
chưa cache), chết sau php artisan config:cache trên production. Grep
User::class trước khi di chuyển: config/auth.php, policy,
Route::model binding, factory, seeder.
Bẫy 4 — thứ tự migration và khoá ngoại. invoices.user_id có FK
trỏ users.id nên bảng users phải được tạo trước. Module nạp theo thứ tự
trong bootstrap/providers.php, mà thứ tự đó dễ bị sửa nhầm khi thêm module. Cách rẻ
nhất là giữ migration create_users_table ở database/migrations gốc,
không đẩy vào module — đúng tinh thần "shared kernel".
Chốt để trả lời phỏng vấn: "Không tách
Userthành module thường — nó là shared kernel, để ởapp/Models, ai cũng import được. Cái tách là hành vi: mỗi module giữ bảng profile riêng và quan hệ đi một chiều vàoUser, nênUserkhông phình. Tách module không làm mất relationship Eloquent — Eloquent chỉ cần tên class. Cái mất là do luật mình tự đặt: cấm import thì mất luônwhereHasvàorderByxuyên bảng, và đó mới là thứ đắt. Chỉ trả giá đó khiuserssắp sang database khác."
2. Lợi ích
| Lợi ích | Chỉ có thật khi… | Đo bằng gì |
|---|---|---|
| Sửa một chỗ, đúng mọi nơi | Code thật sự được gom, không phải copy rồi sửa nhẹ | Số file phải đụng cho một bug giống nhau (trước: 3 – 5, sau: 1) |
| Fresher làm được việc từ tuần đầu | Có module mẫu để copy theo, có checklist review | Thời gian tới MR đầu tiên được merge |
| Review nhanh hơn | Máy đã bắt format/type/ranh giới, người chỉ còn xem nghiệp vụ | Số comment kiểu "sửa indent", "thiếu type" → về 0 |
| Ranh giới rõ → chia việc song song | Mỗi module một chủ, hợp đồng qua interface | Số merge conflict trên file dùng chung mỗi sprint |
| Test rẻ đi | Nghiệp vụ nằm trong service, không dính request | Số test chạy được mà không cần boot HTTP |
| Đường tách service sau này rẻ hơn | Module đã không gọi chéo ruột nhau | Đếm số chỗ import chéo (chính là architecture test ở trên) |
Điểm hay để nói trong phỏng vấn: lợi ích lớn nhất không phải tiết kiệm dòng code, mà là giảm số quyết định mà mỗi dev phải tự nghĩ ra. Fresher không phải chọn "validate ở đâu", "transaction đặt ở đâu", "đặt tên file thế nào" — những chỗ đó đã có một đường mòn.
3. Tác hại / hạn chế
| Hỏng kiểu gì | Dấu hiệu nhận ra | Cách chữa |
|---|---|---|
| Trừu tượng hoá quá sớm | Interface chỉ có đúng một implementation, và sẽ mãi chỉ có một | Xoá interface, để class cụ thể. Tạo lại khi có cái thứ hai thật |
| Base class phình | BaseService 900 dòng, method có 6 tham số $options để "đủ cho mọi case" | Tách theo use-case (action class); base chỉ giữ thứ mọi con đều dùng |
| Ranh giới giả | Module không import chéo, nhưng vẫn JOIN thẳng bảng của nhau trong SQL | Thêm luật cấm tên bảng lạ trong module (grep tương tự architecture test) |
| Event làm coupling ngầm | Sửa payload event → ba module gãy, IDE không tìm ra ai nghe | Event là class có type, không dùng array. Payload event = DTO, coi như API công khai |
| Khó tìm code | "Chức năng điểm danh nằm ở module nào?" hỏi 3 người mới ra | Một bảng module → nghiệp vụ trong README, cập nhật khi thêm module |
| Code chung thành vô chủ | Ai cũng sửa, không ai test, không ai dám xoá | Gán chủ sở hữu (CODEOWNERS), bắt buộc test cho thư mục dùng chung |
| Chuẩn hoá giết tốc độ | Một nút sửa lỗi chính tả cũng phải qua 4 tầng | Cho phép đường tắt có ghi chú: việc CRUD đơn giản được đi thẳng controller → model |
3.1. Cái giá thật: viết chậm hơn, nhiều file hơn — và phiên bản rẻ
Câu team hay kêu, và đúng tới đâu. Không né:
| Team kêu | Đúng tới đâu | Giảm được không |
|---|---|---|
| Nhiều file hơn | Đúng. Một cửa = interface + implementation + DTO = 3 file + 1 dòng binding | Được — stub generator sinh cả 3 trong một lệnh; và chỉ mở cửa khi có người gọi thật |
| Phải tự tổng hợp dữ liệu, code khó đọc | Đúng ở chỗ ghép findMany rồi map tay | Giảm được — gom bước ghép vào một class query, đừng rải ở controller/Livewire |
| Viết feature chậm hơn | Đúng, nhưng chỉ ở feature xuyên module. Lần đầu mở cửa tốn ~30–60 phút, lần sau gần như 0 vì cửa đã có (ước lượng minh hoạ) | Giảm một phần |
Mất whereHas / orderBy xuyên bảng | Đúng — và đây là cái đắt nhất | Không. Đây chính là lý do mặc định đừng đi mức C ở mục 1.9 |
Chỗ hay hiểu sai, và nó đổi toàn bộ phép tính
Luật "chỉ gọi qua Contracts\" áp dụng ở ranh giới, không áp dụng trong ruột
module. Trong một module vẫn viết Laravel bình thường: controller → model → Blade,
Eloquent thẳng, không DTO, không interface.
flowchart LR A["Việc hằng ngày"] --> B["Trong 1 module
~80-90% số task"] A --> C["Xuyên module
~10-20% số task"] B --> D["Eloquent thẳng
Không DTO, không interface
KHÔNG chậm dòng nào"] C --> E["Contracts + DTO
Chậm ở đây thôi"]
Nếu team thấy chậm ở mọi feature thì luật đang bị áp dụng sai chỗ: viết interface cho mọi service, DTO cho mọi hàm. Đó là trừu tượng hoá quá sớm (dòng đầu bảng trên), không phải modular monolith. Số phần trăm ở trên là ước lượng minh hoạ cho một LMS cỡ vừa — đo bằng cách đếm số MR đụng từ 2 module trở lên trong một sprint.
Khi nào cái giá này KHÔNG đáng — nói thẳng
- Team ≤ 3 người, ai cũng biết hết codebase → đừng làm, grep là đủ.
- Domain còn đổi mô hình hàng tuần → vẽ ranh giới sai thì DTO sai theo, sửa đắt gấp đôi. Chờ ổn định.
- Không có architecture test canh → trả tiền mà không nhận hàng: luật không có máy canh thì 2 tháng sau không còn tồn tại.
- App gần như không có màn hình báo cáo / join xuyên bảng → vẫn dùng được, nhưng lợi ích nhỏ hơn nhiều.
Phiên bản rẻ: leo thang, đừng nhảy thẳng lên đỉnh
| Mức | Làm gì | Chi phí | Lên mức sau khi… |
|---|---|---|---|
| 0 — bắt đầu ở đây | Chỉ một architecture test cấm import Domain\ chéo module. Chưa interface, chưa DTO; cửa tạm là một class public | 27 dòng test, một lần | Có cửa bị gọi từ ≥ 2 chỗ |
| 1 | Thêm interface + DTO chỉ cho những cửa đó | 3 file mỗi cửa, thường ~5 cửa cho app cỡ vừa | Test bị chậm vì phải seed dữ liệu module khác |
| 2 | Mọi cửa có interface + DTO + fake trong test | Cao nhất | — |
Hai cách cắt chi phí mà không bỏ luật:
- Stub generator. Một lệnh
artisansinh sẵn interface + implementation + DTO + dòng binding. "Nhiều file" biến thành "một lệnh" — cái đắt là số khái niệm phải nhớ, không phải số file trên đĩa. - DTO viết tay 8 dòng, đừng thêm package.
final readonly class+ constructor property promotion là đủ. Chỉ kéospatie/laravel-datavào khi thật sự cần cast + validation + dựng từ Request ở nhiều chỗ; dưới mức đó nó là dependency thừa.
"Code trông khó đọc" — nó không mất đi, nó đổi chỗ
| Đọc một file | Trả lời "sửa cái này thì gãy chỗ nào" | |
|---|---|---|
| Trước | Dễ — Eloquent thẳng, thấy hết | Grep 30 chỗ, đoán |
| Sau | Khó hơn — thêm một lớp mapping | Grep đúng một tên interface |
Đổi này chỉ lời khi câu hỏi thứ hai được hỏi thường xuyên — tức là team đông, code sống lâu, nhiều người sửa chéo nhau. Team nhỏ ít khi hỏi câu đó, nên áp luật vào là lỗ. Đây cũng là câu trả lời thẳng thắn nên nói trong phỏng vấn, thay vì kể lợi ích một chiều.
Một nỗi lo phổ biến nhưng sai chỗ: "chia nhiều module làm app chậm"
Đo thật trong lab — thêm 30 module, mỗi module một service provider có
loadRoutesFrom + loadViewsFrom, lấy median 10 lần bootstrap:
| Kịch bản | Thời gian bootstrap (median 10 lần) | Số provider |
|---|---|---|
| 1 module, không cache | 207,2 ms | 46 |
| 31 module, không cache | 206,0 ms | 76 |
31 module, có route:cache + config:cache | 201,5 ms | 76 |
Chênh lệch nằm trong nhiễu đo. Nói cách khác: số module gần như không phải là thứ làm
chậm app; cái làm chậm là số route lớn chưa cache, autoload chưa -o, và query.
Đây là kiểu số liệu nên có sẵn khi phỏng vấn — nó biến "em nghĩ là…" thành "em đo rồi".
Ghi rõ giới hạn của phép đo: đây là bootstrap CLI (
artisan), provider rỗng, máy dev chạy Docker. Provider thật có đăng ký binding, event, policy thì tốn hơn; nhưng bậc độ lớn vẫn là vài ms cho vài chục module — chưa verify trên môi trường production có OPcache + preload.
4. Trade-off
4.1. Gom code chung tới mức nào
flowchart LR A["Copy-paste
(0 chi phí, 2 chỗ lệch nhau)"] --> B["Gom trong 1 module
trait / scope / component"] B --> C["Contracts giữa module
(cùng repo)"] C --> D["Package Composer
(nhiều repo, có version)"] D --> E["Service riêng qua HTTP
(deploy riêng)"] R["Rule of three:
lần 1, 2 cứ copy — lần 3 mới gom"] -.-> B S["Chỉ sang D khi có repo thứ 2 thật
Chỉ sang E khi cần scale/deploy riêng"] -.-> D
| Tiêu chí | Copy-paste | Module cùng repo | Package Composer |
|---|---|---|---|
| Chi phí sửa một lỗi chung | n chỗ, dễ sót | 1 MR | 1 MR + tag + n lần bump + n lần deploy |
| Rủi ro làm gãy người khác | 0 | Trung bình (CI bắt) | Cao (version lệch, deploy lệch giờ) |
| Tốc độ làm tính năng mới | Nhanh nhất | Nhanh | Chậm nhất |
| Hợp khi | Hai chỗ trông giống nhau nhưng nghiệp vụ khác | Cùng một nghiệp vụ, cùng một sản phẩm | Từ 2 repo trở lên cần đúng cùng một thứ |
4.2. Các cặp chọn lựa hay bị hỏi
| Chọn giữa | Chọn A khi | Chọn B khi | Tôi chọn |
|---|---|---|---|
| Service (nhiều method) vs Action (một việc) | Nhóm việc gắn chặt nhau, dùng chung state/private helper | Việc độc lập, cần queue được, cần test riêng | Action cho luồng ghi (đăng ký, nộp bài); Service cho cụm đọc/báo cáo |
| Eloquent thẳng vs Repository | Gần như luôn luôn — Eloquent đã là repository | Khi nguồn dữ liệu thật sự không phải DB (API ngoài, file) | Eloquent thẳng; đóng gói query phức tạp bằng scope hoặc query object, không bằng repository |
| Tự dựng module (~15 dòng) vs nwidart/laravel-modules | Team < 10 người, muốn ít thứ phải nâng cấp | Cần generator, cần chuẩn chung nhiều team, có người lo nâng cấp | Tự dựng — thứ nó thay thế chỉ là một service provider |
| DTO vs array qua ranh giới | Dữ liệu đi qua nhiều tầng, cần IDE gợi ý và static analysis bắt lỗi | Dữ liệu chỉ đi 1 bước, hoặc đúng payload từ API ngoài | DTO ở cửa module và cửa Livewire (xem Blade nhận gì) |
| DTO vs Eloquent model qua cửa | Luôn luôn — qua Contracts\ thì trả DTO (hoặc scalar) | Trong cùng một module; hoặc User vì là shared kernel | Trả model qua cửa buộc bên gọi import Domain\ của module khác — xem mục 1.5.1 |
| Interface cho mọi service vs chỉ ở ranh giới | Không bao giờ — tạo gấp đôi file mà không đổi được gì | Ở cửa module, ở chỗ có thật 2 implementation, ở chỗ gọi hệ thống ngoài | Chỉ ở ranh giới |
| Ép chuẩn từ đầu vs để tự nhiên rồi gom | Có fresher, nhiều người mới vào cùng lúc | Team ít người, senior, domain chưa rõ | Ép 3 thứ tối thiểu (tầng, tên, strict model), còn lại gom dần theo rule of three |
5. Khó khăn khi vận hành thực tế
| Vấn đề | Nó đau ở đâu | Cách tôi xử lý |
|---|---|---|
| Ai sở hữu code chung | Không có chủ → không ai refactor, chỉ có người thêm vào | CODEOWNERS: thư mục app/ và packages/ bắt buộc lead review; module thì chủ module review |
| Đổi code chung làm gãy chỗ không ngờ | Sửa một trait, 4 module gãy lúc 3h sáng | Code chung bắt buộc có test; CI chạy toàn bộ test (không chạy theo thư mục đổi) cho MR đụng app/ |
| Version package nội bộ | App A dùng v1.3, app B còn v1.0, bug fix chỉ vào một bên | Semver + CHANGELOG.md; breaking change thì thêm cái mới, deprecate cái cũ, xoá ở major sau |
| Dev phải mở 2 repo | Sửa package + sửa app trong cùng một task, dễ quên commit một bên | Dev bằng path repository (symlink, sửa là thấy ngay), chỉ chuyển sang version thật lúc release |
| CI chậm dần | Thêm luật, thêm test, pipeline 4 phút → 15 phút, dev bắt đầu bỏ qua | Chạy song song (Pint + Larastan + test là 3 job), cache vendor, tách test chậm sang job riêng chỉ chạy trên nhánh chính (xem pipeline GitLab) |
| Quy ước trôi | 3 tháng sau mỗi module một kiểu vì luật chỉ nằm trong đầu lead | Mỗi luật phải có một cái chặn bằng máy; luật không tự động hoá được thì ghi ADR ngắn và chấp nhận nó sẽ trôi |
| Onboarding | Người mới không biết bắt đầu từ đâu trong 8 module | Một module mẫu nhỏ nhất làm bản tham chiếu + task đầu tiên là thêm một màn hình trong chính module đó |
| Debug lúc 3h sáng | Stack trace đi qua 5 tầng trừu tượng, khó biết lỗi ở module nào | Trace OTel gắn module làm attribute; exception của module bọc trong exception có mã (LEARNING_ENROLL_FAILED) — xem bộ công cụ vận hành |
6. Case thực tế và cách xử lý
Case 1 — Hàm tính điểm bị copy 3 chỗ, sửa 1 chỗ, báo cáo lệch
Hiện tượng: điểm hiển thị cho sinh viên là 7,5; export Excel ra 7,8; dashboard giảng viên lại ra 7,5 nhưng xếp loại sai.
flowchart TB R["Yêu cầu: làm tròn 0,25 thay vì 0,5"] --> A["Sửa ở Livewire component (màn sinh viên)"] R --> B["Quên: ExportGradesJob"] R --> C["Quên: ReportController"] A --> X["3 kết quả khác nhau cho cùng một sinh viên"] B --> X C --> X
Xử lý từng bước:
- Chặn máu trước: tìm hết bản sao —
grep -rn "round(" --include=*.php | grep -i diem. Chọn một bản đúng theo quy chế, sửa 2 bản còn lại cho khớp, deploy. Chưa refactor gì cả. - Gom (lần thứ 3 rồi, đủ điều kiện): đưa vào một chỗ duy nhất —
Modules\Learning\Domain\GradeCalculator, methodfinal, không có tham số$mode. - Test theo bảng quy chế: data provider với các mốc 7,124 / 7,125 / 7,375… Test này là tài liệu quy chế bằng code.
- Chặn tái phát: thêm một luật vào architecture test — cấm chuỗi
round(trongHttp/và trong Job; muốn tính điểm thì gọiGradeCalculator. - Đóng vòng: ADR 15 dòng "quy tắc làm tròn điểm nằm ở đâu và vì sao", link trong README module.
Chỗ dễ trả lời sai khi phỏng vấn: nhảy ngay vào "em tạo service dùng chung". Câu trả lời tốt phải có bước 1 (sửa lệch dữ liệu trước) và bước 4 (chặn bằng máy, không bằng lời dặn).
Case 2 — BaseService phình thành 900 dòng
Hiện tượng: mọi service extends BaseService. Method
create(array $data, array $options = []) có 7 nhánh if ($options['skip_...']).
Sửa cho module Billing thì test module Learning đỏ.
Xử lý:
- Đếm thật xem con nào dùng gì:
grep -rn "parent::" modules/ | sort | uniq -c. Thường ra kết quả: 80% method của base chỉ 1 – 2 con dùng. - Phần nhiều con dùng và không có
if(ví dụ ghi audit log) → chuyển thành trait hoặc event listener, không phải kế thừa. - Phần có
$options→ tách thành action riêng cho từng nghiệp vụ:EnrollStudent,TransferStudent,CancelEnrollment. Trùng vài dòng — chấp nhận, vì chúng sẽ phân hoá tiếp. - Làm dần: mỗi sprint kéo 1 – 2 service ra khỏi base; base teo dần rồi xoá. Không làm một MR "refactor toàn bộ" — loại MR đó không ai review nổi và không rollback được.
Dấu hiệu để biết khi nào một base class bắt đầu hỏng (dùng luôn làm tiêu chí review):
tham số $options/$flags xuất hiện; con phải override rồi không gọi
parent::; base phải instanceof con để rẽ nhánh.
Case 3 — Deadline gấp, dev import thẳng model module khác
Hiện tượng: MR gấp trước ngày khai giảng, Billing import
Modules\Learning\Domain\Enrollment. Pipeline đỏ ở job architecture test (output thật ở
mục 1.6).
Xử lý — và đây là chỗ thể hiện vai trò lead:
- Không tắt test. Cho phép một đường hợp lệ: Learning thêm method vào
EnrollmentReader(5 phút), Billing dùng interface. - Nếu thật sự không kịp: thêm một dòng ngoại lệ có hạn vào file luật, kèm comment
// TODO(#1234) gỡ trước 30/09và issue thật. Ngoại lệ phải nhìn thấy được, không phải@phpstan-ignorerải rác. - Retro: nếu tháng nào cũng phải xin ngoại lệ ở cùng một ranh giới → ranh giới đó vẽ sai, gộp hai module lại. Ranh giới là giả thuyết, không phải điều răn.
Case 4 — Package nội bộ chạy ở máy dev, chết trên production
Hiện tượng: deploy xong, trang thanh toán 500 hàng loạt:
BadMethodCallException: Method Illuminate\Support\Str::maSinhVien does not exist.
Ở máy dev chạy hoàn toàn bình thường.
Nguyên nhân (đã dựng lại trong lab): image production build bằng
composer install --no-dev --no-scripts. --no-scripts chặn luôn
package:discover → service provider của package không được đăng ký → macro
không tồn tại. Class thì vẫn autoload được nên static analysis không bắt.
Xử lý:
- Ngay lúc đó: rollback bằng đổi upstream (blue-green), không debug trên production.
- Sửa gốc: thêm
php artisan package:discover --ansivào Dockerfile ngay saucomposer install; hoặc khai thẳng provider vàobootstrap/providers.php— rõ ràng hơn auto-discovery, và đây là cách tôi ưu tiên cho package nội bộ. - Chặn tái phát: smoke test sau deploy gọi một route dùng macro đó; job CI build image rồi chạy
php artisan about+ smoke test trên chính image sắp deploy, không phải trên máy dev.
Bài học đáng nói trong phỏng vấn: lỗi không nằm ở code, nằm ở khác biệt giữa môi trường build và môi trường dev. Mọi thứ "tự động" (auto-discovery, cache, symlink) đều là chỗ hai môi trường có thể lệch nhau.
Case 5 — Bật strict model cho codebase đang chạy
Hiện tượng: bật Model::shouldBeStrict() cho cả 3 môi trường, hôm sau
production ném LazyLoadingViolationException ở màn hình danh sách lớp — chỗ mà
dev test local (1 lớp) không hề thấy lỗi (bẫy count($items) > 1 ở mục 1.7).
Xử lý:
- Production không ném exception:
Model::shouldBeStrict(! app()->isProduction()). - Production vẫn muốn biết → ghi log thay vì ném:
Có số liệu thật để biết chỗ nào đáng sửa trước, mà người dùng không thấy lỗi.Model::handleLazyLoadingViolationUsing(function ($model, $relation) { Log::warning("lazy_load", ["model" => $model::class, "relation" => $relation]); }); - Seed dữ liệu dev phải có nhiều dòng cho mỗi bảng chính — nếu không, cả strict mode lẫn mắt người đều không thấy N+1.
- Bật dần theo model: dùng
Model::preventLazyLoading()toàn cục sau khi đã dọn xong danh sách trong log, đừng bật một phát rồi tắt vì "ồn quá".
7. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| "Đây có phải DDD không?" | Không. Mượn ý ranh giới (bounded context) và ngôn ngữ chung, nhưng không có aggregate/repository/domain event đầy đủ. Gọi đúng tên: modular monolith. |
| "Sao không dùng Repository pattern?" | Eloquent đã là repository. Bọc thêm chỉ để "đổi ORM sau" là việc chưa ai từng làm; đổi lấy hàng trăm file và mất eager loading, scope, pagination sẵn có. |
| "Sao không dùng nwidart/laravel-modules?" | Nó thay thế đúng một service provider 15 dòng, đổi lại thêm một dependency phải nâng cấp theo Laravel. Team < 10 người thì không đáng. |
| "Làm sao ép team tuân thủ?" | Mỗi luật phải có một cái chặn bằng máy trong CI (Pint, Larastan, architecture test). Luật chỉ nằm trong tài liệu thì 3 tháng sau là tài liệu sai. |
| "Đo mức tái sử dụng bằng gì?" | Không đo bằng "số dòng tiết kiệm". Đo bằng: số file phải đụng để sửa một bug thuộc loại đã gặp, số MR bị CI chặn vì vi phạm ranh giới, thời gian fresher tới MR đầu tiên. |
| "Khi nào tách module thành microservice?" | Khi có lý do vận hành: cần scale riêng, cần deploy riêng, cần đội riêng, hoặc hồ sơ tài nguyên khác hẳn. Không tách vì "cho sạch". Trước đó module đã cấm gọi chéo ruột nên chi phí tách không tăng. |
| "Code chung ai review, ai chịu trách nhiệm?" | CODEOWNERS: app/ và packages/ lead review bắt buộc, module thì chủ module. Code chung bắt buộc có test mới được merge. |
| "Breaking change ở package nội bộ xử lý sao?" | Thêm cái mới → deprecate cái cũ → xoá ở major sau. Semver + CHANGELOG. Không sửa thẳng chữ ký hàm rồi đi bump từng repo. |
| "Chia nhiều module có làm app chậm không?" | Đo rồi: 1 vs 31 module chênh nhau nằm trong nhiễu (207 ms vs 206 ms bootstrap CLI). Thứ làm chậm là query và route chưa cache, không phải số provider. |
| "Bao giờ thì không nên gom code chung?" | Khi hai chỗ chỉ trông giống nhau (cùng là "create") nhưng thuộc hai nghiệp vụ sẽ phân hoá. Gom sớm tạo ra tham số $options — dấu hiệu gom nhầm. |
| "Team phản đối quy ước thì làm sao?" | Hỏi họ luật nào chặn việc. Luật nào bị xin ngoại lệ 3 lần thì luật đó sai, sửa luật — không ép. Ranh giới là giả thuyết, không phải điều răn. |
| "Có dùng AI sinh code theo chuẩn không?" | Có — module mẫu và CLAUDE.md/rule file chính là spec cho AI. Nhưng CI vẫn là cửa chặn cuối; code AI sinh ra cũng phải qua đúng ba lớp đó. |
Trả lời trong 60 giây (bản dùng khi phỏng vấn)
"Repo là một monolith chia module: mỗi module một thư mục, một service provider tự nạp route, view, migration — tổng cộng khoảng 15 dòng framework, không thêm dependency. Module chỉ gọi nhau qua
Contractsvà DTO, và luật đó do một test kiến trúc 27 dòng canh, không phải do em nhắc trong review. Trong module thì tách controller → service/action → model; service không đụng request nên gọi lại được từ Livewire, command và test.Thứ dùng chung em chỉ giữ loại ép ràng buộc — base model bật
shouldBeStrict, format lỗi, tenant scope — không giữ base class gom nghiệp vụ, vì loại đó luôn phình thành 900 dòng với một đống$options. Code chỉ được gom khi xuất hiện lần thứ ba, và chỉ tách thành package Composer khi có repo thứ hai thật sự cần.Bẫy em đã gặp:
composer install --no-scriptslàm package nội bộ không được discover → 500 trên production dù dev chạy tốt; và strict mode không bắt lazy load khi query trả về đúng một dòng — nên seed dev phải nhiều dòng, còn production thì log thay vì ném lỗi."
Đọc tiếp: Tối ưu Laravel (N+1, eager loading), Blade nhận gì — Model, array hay Collection (DTO qua ranh giới view), Pint + static analysis (hai lớp chặn đầu trong CI).