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 classService 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
contractInterface. 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 bindingKhai báo với Laravel: "ai xin interface X thì đưa class Y". Sau đó chỉ cần type-hint interface ở constructor
macroGắn thêm method vào class có sẵn của Laravel lúc chạy (Str::macro, Builder::macro)
package auto-discoveryComposer package tự khai service provider trong extra.laravel.providers; Laravel tự đăng ký, người dùng không phải khai tay
path repositoryKiể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 modelModel::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/lab bên trong container: copy code project, symlink vendor/, database SQLite riêng — không đụng chiennn_commerce_postgres và 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.

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.

Cấu trúc phẳng cho phép mọi class gọi thẳng nhau; modular monolith bắt đi qua cửa trước Bên trái: sáu class service trong một thư mục phẳng, chín mũi tên gọi chéo nhau tạo thành mạng lưới, kèm cảnh báo "sửa một chỗ không biết chỗ nào gãy". Bên phải: ba module Learning, Billing, Reporting xếp dọc, mỗi module có dải Contracts ở trên là cửa trước; mũi tên chỉ đi từ module này sang cửa trước của module kia, còn đường đi thẳng vào ruột module bị đánh dấu cấm. Phẳng + Service layer app/Services/ — ai cũng gọi thẳng được ai Enroll Invoice Report Mail Point Export Sửa Invoice → ai gãy? Phải grep cả app/ Modular monolith gọi nhau qua Contracts — ruột đóng lại Contracts\ + Data\ — cửa trước Contracts\ + Data\ — cửa trước Contracts\ + Data\ — cửa trước Learning Billing Reporting Domain · Services · Http — ruột Domain · Services · Http — ruột Domain · Services · Http — ruột cấm
Trái: không có luật nào chặn, nên sau 6 tháng đồ thị phụ thuộc thành mạng lưới — mỗi lần sửa một service phải tự đi tìm ai đang dùng. Phải: mũi tên xanh chỉ được cắm vào dả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ánPhẳng + Service layerCó 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 moduleBiểu hiện
Phải quyết định ranh giới sớm, lúc còn chưa hiểu domainChia 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áchMỗi MR phải sửa cả contract lẫn 2 implementation
Query chéo module trở nên phiềnBá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àmCấm
Controller / LivewireNhận input, gọi 1 service, trả view/redirectQuery, DB::, if nghiệp vụ, transaction
FormRequestValidate, authorizeGhi dữ liệu
Service / ActionNghiệp vụ, transaction, dispatch eventĐụng request(), session(), auth() — nhận qua tham số
ModelQuan hệ, cast, scope, accessorGửi mail, gọi API ngoài, logic nhiều bước
Job / ListenerViệc chạy nền, gọi lại serviceChứ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ênNên?Vì sao
app/Models/BaseModel bật strict + cast chungNênMột dòng ở một chỗ, ép cả team, không ai phải nhớ
Contracts/ + container binding giữa moduleNênLà ranh giới thật, test thay thế được bằng fake
Trait HasUuid, BelongsToTenantNênHà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ênGiao diện lệch nhau là lỗi người dùng thấy ngay
Exception + handler chung (mã lỗi, format JSON)NênClient chỉ cần học một format
BaseService có create/update/deleteKhôngMỗ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 EloquentKhôngEloquent đã 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->serviceKhôngRà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
ReturnScalar nếu đủ (int, bool, string) → readonly DTO khi nhiều field → không bao giờ là Eloquent model
ParameterScalar 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ấtUser — 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.

Cửa trả Eloquent model rò ba đường; cửa trả DTO thì không có gì để chạm Bên trái: module Learning có dải Contracts ở trên và ba ô ruột bên dưới là bảng enrollments, cột status, quan hệ course. Một mũi tên xanh trả model Enrollment sang Billing; sau đó ba mũi tên đỏ từ Billing chạy ngược vào đúng ba ô ruột đó, tương ứng với gọi update, đi tiếp quan hệ course teacher, và đọc cột status. Bên phải: cửa trả DTO EnrollmentSummary, ba mũi tên đỏ đi ngược bị chặn ngay ngoài mép module và đánh dấu bằng dấu nhân, kèm ghi chú muốn ghi thì phải gọi enroll. Cửa trả Eloquent model find(int $id): Enrollment Contracts\ Learning enrollments (bảng) cột status course() quan hệ ruột — lẽ ra đóng Billing Enrollment $e->update() $e->course->teacher $e->status Billing chạm thẳng vào ruột Learning Cửa trả DTO summaries(array $ids): array<EnrollmentSummary> Contracts\ + Data\ Learning enrollments (bảng) cột status course() quan hệ ruột — đóng thật Billing EnrollmentSummary không có ruột để chạm muốn ghi → gọi enroll(); muốn field mới → sửa DTO
Trái: model đi qua cửa thì ba thứ rò ra cùng lúc — quyền ghi (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ả readonly DTO. 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à User vì 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àm hydrate):

$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-scripts thì 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 exist ngay 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 tay php artisan package:discover trong Dockerfile, hoặc khai thẳng provider vào bootstrap/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ớiDo cái gì tạo raCó chặn relationship không
Kỹ thuật — Composer PSR-4, namespaceComposerKhông. belongsTo, with(), whereHas() chạy y như cũ
Kỷ luật — "chỉ gọi qua Contracts\"Quy ước + architecture testCó. 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ứcUser nằm ở đâuModule khác được làm gìRelationshipKhi nào chọn
A. Shared kernel
mặc định
app/Models/User.phpImport thoải máiGiữ nguyên 100%Monolith một database. Chọn cái này.
B. Module + whitelistmodules/Identity/Domain/User.phpĐọc model được; ghi phải qua ContractGiữ nguyênĐội đông, muốn kiểm soát chỗ ghi (khoá tài khoản, đổi email)
C. Tách cứngmodules/Identity/, cấm importChỉ UserReader::findMany(): arrayMất with, whereHas, joinChỉ 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 phình vì mọi module nhét method vào, rồi mỏng lại khi mỗi module giữ bảng riêng Giai đoạn 1: ba module đẩy method charge(), enroll(), exportReport() vào class User, User phình lên 800 dòng. Giai đoạn 2: các method quay về module dưới dạng bảng riêng billing_profiles, learning_enrollments, report_snapshots, mỗi bảng có quan hệ belongsTo trỏ vào User; User chỉ còn id, name, email. Billing Learning Reporting app/Models/User.php id, name, email charge() enroll() exportReport() ⋮ 800 dòng — ai cũng sợ sửa app/Models/User.php id, name, email — hết charge() enroll() exportReport() billing_profiles learning_enrollments report_snapshots belongsTo(User::class)
Giai đoạn 1 (viền đỏ): mỗi module đẩy method nghiệp vụ của mình vào 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-read trong PHPDoc của User, tức là User vẫ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ùng resolveRelationUsing khi 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:

Số query khi eager load so với khi gọi Contract từng dòng Làn trên, mức A dùng with('user'): hai query chạy gần như cùng lúc, tổng 2 query. Làn dưới, mức C gọi Contract cho từng hoá đơn: một query lấy hoá đơn rồi năm query lấy user, bắn lần lượt, tổng 6 query cho 5 dòng dữ liệu. Mức A — with('user') 2 query — xong Mức C — $reader->find() từng dòng 6 query (1 + 5) 5 hoá đơn — 5 dòng thôi đã lệch 3 lần; production một trang 50 dòng là 51 query
Cùng một dữ liệu, cùng một màn hình. Khác nhau ở chỗ mức A để Eloquent gom id rồi bắn một câu 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 CHậ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 trangSắ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áoReporting 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 User thà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ào User, nên User khô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ôn whereHas và orderBy xuyên bảng, và đó mới là thứ đắt. Chỉ trả giá đó khi users sắp sang database khác."

2. Lợi ích

Lợi íchChỉ có thật khi…Đo bằng gì
Sửa một chỗ, đúng mọi nơiCode 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 đầuCó module mẫu để copy theo, có checklist reviewThời gian tới MR đầu tiên được merge
Review nhanh hơnMá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 songMỗi module một chủ, hợp đồng qua interfaceSố merge conflict trên file dùng chung mỗi sprint
Test rẻ điNghiệp vụ nằm trong service, không dính requestSố test chạy được mà không cần boot HTTP
Đường tách service sau này rẻ hơnModule đã 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 raCách chữa
Trừu tượng hoá quá sớmInterface chỉ có đúng một implementation, và sẽ mãi chỉ có mộtXoá interface, để class cụ thể. Tạo lại khi có cái thứ hai thật
Base class phìnhBaseService 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 SQLThêm luật cấm tên bảng lạ trong module (grep tương tự architecture test)
Event làm coupling ngầmSửa payload event → ba module gãy, IDE không tìm ra ai ngheEvent 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 raMộ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ầngCho 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 đâuGiả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 tayGiả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ấtKhô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

Phiên bản rẻ: leo thang, đừng nhảy thẳng lên đỉnh

MứcLàm gìChi phíLên mức sau khi…
0 — bắt đầu ở đâyChỉ 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 public27 dòng test, một lầnCó cửa bị gọi từ ≥ 2 chỗ
1Thêm interface + DTO chỉ cho những cửa đó3 file mỗi cửa, thường ~5 cửa cho app cỡ vừaTest bị chậm vì phải seed dữ liệu module khác
2Mọi cửa có interface + DTO + fake trong testCao nhất—

Hai cách cắt chi phí mà không bỏ luật:

  1. Stub generator. Một lệnh artisan sinh 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.
  2. DTO viết tay 8 dòng, đừng thêm package. final readonly class + constructor property promotion là đủ. Chỉ kéo spatie/laravel-data và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 fileTrả lời "sửa cái này thì gãy chỗ nào"
TrướcDễ — Eloquent thẳng, thấy hếtGrep 30 chỗ, đoán
SauKhó hơn — thêm một lớp mappingGrep đú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ảnThời gian bootstrap (median 10 lần)Số provider
1 module, không cache207,2 ms46
31 module, không cache206,0 ms76
31 module, có route:cache + config:cache201,5 ms76

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-pasteModule cùng repoPackage Composer
Chi phí sửa một lỗi chungn chỗ, dễ sót1 MR1 MR + tag + n lần bump + n lần deploy
Rủi ro làm gãy người khác0Trung bình (CI bắt)Cao (version lệch, deploy lệch giờ)
Tốc độ làm tính năng mớiNhanh nhấtNhanhChậm nhất
Hợp khiHai chỗ trông giống nhau nhưng nghiệp vụ khácCùng một nghiệp vụ, cùng một sản phẩmTừ 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ữaChọn A khiChọn B khiTô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 helperViệc độc lập, cần queue được, cần test riêngAction cho luồng ghi (đăng ký, nộp bài); Service cho cụm đọc/báo cáo
Eloquent thẳng vs RepositoryGần như luôn luôn — Eloquent đã là repositoryKhi 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-modulesTeam < 10 người, muốn ít thứ phải nâng cấpCần generator, cần chuẩn chung nhiều team, có người lo nâng cấpTự dựng — thứ nó thay thế chỉ là một service provider
DTO vs array qua ranh giớiDữ liệu đi qua nhiều tầng, cần IDE gợi ý và static analysis bắt lỗiDữ liệu chỉ đi 1 bước, hoặc đúng payload từ API ngoàiDTO ở cửa module và cửa Livewire (xem Blade nhận gì)
DTO vs Eloquent model qua cửaLuôn luôn — qua Contracts\ thì trả DTO (hoặc scalar)Trong cùng một module; hoặc User vì là shared kernelTrả 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ớiKhô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àiChỉ ở ranh giới
Ép chuẩn từ đầu vs để tự nhiên rồi gomCó fresher, nhiều người mới vào cùng lúcTeam í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 ở đâuCách tôi xử lý
Ai sở hữu code chungKhông có chủ → không ai refactor, chỉ có người thêm vàoCODEOWNERS: 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ángCode 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ênSemver + CHANGELOG.md; breaking change thì thêm cái mới, deprecate cái cũ, xoá ở major sau
Dev phải mở 2 repoSửa package + sửa app trong cùng một task, dễ quên commit một bênDev 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ầnThêm luật, thêm test, pipeline 4 phút → 15 phút, dev bắt đầu bỏ quaChạ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ôi3 tháng sau mỗi module một kiểu vì luật chỉ nằm trong đầu leadMỗ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
OnboardingNgười mới không biết bắt đầu từ đâu trong 8 moduleMộ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ángStack trace đi qua 5 tầng trừu tượng, khó biết lỗi ở module nàoTrace 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:

  1. 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ả.
  2. Gom (lần thứ 3 rồi, đủ điều kiện): đưa vào một chỗ duy nhất — Modules\Learning\Domain\GradeCalculator, method final, không có tham số $mode.
  3. 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.
  4. Chặn tái phát: thêm một luật vào architecture test — cấm chuỗi round( trong Http/ và trong Job; muốn tính điểm thì gọi GradeCalculator.
  5. Đó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ý:

  1. Đế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.
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. 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/09 và issue thật. Ngoại lệ phải nhìn thấy được, không phải @phpstan-ignore rải rác.
  3. 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ý:

  1. Ngay lúc đó: rollback bằng đổi upstream (blue-green), không debug trên production.
  2. Sửa gốc: thêm php artisan package:discover --ansi vào Dockerfile ngay sau composer install; hoặc khai thẳng provider vào bootstrap/providers.php — rõ ràng hơn auto-discovery, và đây là cách tôi ưu tiên cho package nội bộ.
  3. 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ý:

  1. Production không ném exception: Model::shouldBeStrict(! app()->isProduction()).
  2. Production vẫn muốn biết → ghi log thay vì ném:
    Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
        Log::warning("lazy_load", ["model" => $model::class, "relation" => $relation]);
    });
    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.
  3. 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.
  4. 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ỏiTrả 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 Contracts và 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-scripts là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).