Từ Traits sang Services: Refactoring để Tăng Khả Năng Kiểm Thử và Hỗ Trợ AI

06 tháng 4, 2026·8 phút đọc

PHP traits tiện lợi nhưng thường dẫn đến các phụ thuộc ẩn và khó kiểm thử. Bài viết này phân tích quá trình tái cấu trúc 6 traits thành các service class chuyên biệt, giúp cải thiện kiến trúc code, tối ưu hóa testing và tạo điều kiện thuận lợi cho các AI Agent.

Từ Traits sang Services: Refactoring để Tăng Khả Năng Kiểm Thử và Hỗ Trợ AI

Traits trong PHP thực sự rất hấp dẫn. Bạn có một chút logic thông báo chat, bốn controllers cần sử dụng nó. Việc bạn cần làm chỉ là bỏ nó vào một trait, thêm use ChatNotificationTrait, và xong việc.

Nhưng rồi vấn đề bắt đầu nảy sinh:

  • Phụ thuộc ẩn (Hidden dependencies) — Trait gọi các phương thức $this-> vốn không tồn tại trong chính nó.
  • Sự kết hợp vô hình (Invisible coupling) — Thay đổi trait, bạn có thể làm hỏng bốn controllers, và chúc bạn may mắn khi tìm ra xem cái nào bị lỗi.
  • Logic khó kiểm thử (Untestable logic) — Bạn không thể unit test một trait một cách độc lập vì nó không "tồn tại" tách biệt.
  • Mùi của trạng thái toàn cục (Global state smell) — Traits khuyến khích việc truy cập sâu vào các thuộc tính của controller.

Trong codebase mà tôi đang xử lý, có sáu traits đang thực hiện các công việc quan trọng:

TraitChức năng
ChatNotificationTraitGửi chat webhooks
CrmApiTraitĐồng bộ CRM
OcrScanApiTraitOCR qua API quét tài liệu
ConvertApiTraitChuyển đổi tài liệu
ExternalApiTraitTích hợp API bên thứ ba
CalculationTraitTính toán nghiệp vụ

Mỗi trait được sử dụng trong nhiều controllers. Mỗi cái trộn lẫn logic HTTP client, quy tắc nghiệp vụ, xử lý lỗi và cấu hình vào một câu lệnh use đơn lẻ. Việc kiểm thử bất kỳ cái nào trong số đó đồng nghĩa với việc phải kiểm tra toàn bộ controller.

Kế hoạch Tái cấu trúc

Tôi đã lên kế hoạch cho cả sáu lần trích xuất ngay từ đầu nhưng thực hiện chúng từng cái một, trong các Pull Request (PR) riêng biệt. Mỗi PR bao gồm các bước:

  1. Tạo hợp đồng (interface).
  2. Tạo triển khai service.
  3. Khai báo interface trong Service Provider.
  4. Cập nhật tất cả controllers để tiêm (inject) service thay vì dùng trait.
  5. Chạy bộ test đầy đủ.

Trait được giữ lại trong codebase cho đến khi mọi nơi sử dụng nó đều được di chuyển xong. Sau đó nó mới bị xóa. Không có thời điểm nào ứng dụng bị lỗi cả.

Đây là nguyên tắc "làm cho thay đổi trở nên dễ dàng, sau đó thực hiện sự thay đổi dễ dàng đó" của Kent Beck. Mỗi lần trích xuất là một bước nhỏ, an toàn. Các bài test bắt được mọi thay đổi về hành vi. Công cụ linting bắt được các vấn đề về cấu trúc.

Thiết kế ưu tiên Interface (Contract-First)

Mọi lần trích xuất đều bắt đầu bằng một interface:

namespace App\Services\Notifications\Contracts;

interface NotificationInterface
{
    public function sendOrderNotification(Order $order, string $message): void;
    public function sendTicketNotification(Ticket $ticket, string $message): void;
}

Sau đó là phần triển khai:

namespace App\Services\Notifications;

class ChatNotificationService implements NotificationInterface
{
    public function __construct(
        private string $webhookUrl,
        private HttpClient $http,
    ) {}

    public function sendOrderNotification(Order $order, string $message): void
    {
        $this->send($this->formatOrderMessage($order, $message));
    }

    // ...
}

Tại sao lại cần interface? Có ba lý do:

  1. Khả năng kiểm thử (Testability) — Bạn có thể mock NotificationInterface trong các bài test mà không cần bận tâm đến các chat webhooks.
  2. Khả năng thay đổi (Swappability) — Khi chúng tôi chuyển từ chat webhooks sang kênh thông báo khác, interface vẫn giữ nguyên.
  3. Ranh giới (Boundaries) — Interface định nghĩa service làm . Triển khai định nghĩa làm như thế nào. Người tiêu dùng (consumers) chỉ cần biết về cái "gì".

Quy trình thực hiện

Tôi thực hiện chúng theo một trật tự có chủ đích, bắt đầu từ cái đơn giản nhất:

  1. ChatNotificationTrait → ChatNotificationService: Trích xuất đơn giản nhất. Các cuộc gọi HTTP webhook với định dạng thông báo. Không có trạng thái phức tạp.
  2. CrmApiTrait → CRM service classes: Phức tạp hơn — API ghi hàng loạt, theo dõi đồng bộ, chuyển đổi DTO. Nhưng interface thì sạch sẽ: đồng bộ người dùng, đồng bộ đơn hàng.
  3. OcrScanApiTrait → DocumentScanner service: Tích hợp OCR. Được trích xuất phía sau DocumentScannerInterface để có thể đổi nhà cung cấp OCR sau này.
  4. ConvertApiTrait → Document conversion services: Chuyển đổi định dạng tài liệu. Một wrapper cho HTTP client đơn giản.
  5. ExternalApiTrait → ExternalApiClient service: Tích hợp API bên thứ ba. Xác thực, ký yêu cầu, phân tích phản hồi.
  6. CalculationTrait → CalculatorService: Trích xuất phức tạp nhất. Logic tính toán nghiệp vụ với theo dõi cấu hình lịch sử. Cái này cần mô hình ConfigHistory để tách biệt tính toán khỏi trạng thái controller.

Mỗi cái mất khoảng một ngày. Toàn bộ chuỗi mất khoảng hai tuần. Không bao giờ làm hỏng ứng dụng. Người dùng không hề hay biết gì.

Đủ mức trừu tượng

Cụm từ khóa là "đủ mức trừu tượng để mọi thứ tiếp tục hoạt động". Khi bạn trích xuất ChatNotificationTrait thành ChatNotificationService, các controllers đang dùng $this->sendChatNotification() giờ sẽ gọi $this->notificationService->sendOrderNotification().

Nhưng bạn không thay đổi tất cả controllers cùng lúc. Bạn sẽ:

  1. Tạo service.
  2. Khai báo nó trong service provider.
  3. Cập nhật một controller.
  4. Chạy bài test.
  5. Cập nhật controller tiếp theo.
  6. Chạy bài test lại.

Nếu có gì bị lỗi, bạn biết chính xác thay đổi controller nào gây ra nó. Các bước nhỏ. Phản hồi nhanh. Thực nghiệm hơn là giáo điều.

Tại sao ranh giới (Boundaries) giúp ích cho AI Agents?

Đây là điều tôi không đánh giá cao cho đến sau này: ranh giới rõ ràng giúp các tác nhân AI (agents) tốt hơn cả tài liệu.

Khi sau này tôi bắt đầu sử dụng Claude để xây dựng tính năng, tác nhân có thể nhìn vào App\Services\Notifications\Contracts\NotificationInterface và hiểu ngay lập tức:

  • Các khả năng thông báo nào tồn tại.
  • Các tham số chúng nhận là gì.
  • Cách sử dụng chúng (tiêm interface, gọi phương thức).

So sánh với thế giới của traits, nơi tác nhân sẽ phải:

  • Tìm trait.
  • Đọc trait để hiểu các phương thức nó cung cấp.
  • Chỉ ra các thuộc tính controller mà trait phụ thuộc vào.
  • Hy vọng nó đang sử dụng trait đúng cách.

Interface của service là tự tài liệu hóa (self-documenting). Trait là một hộp bí ẩn.

Kiến trúc là tài liệu tốt nhất cho các tác nhân AI. Nếu cấu trúc code rõ ràng, tác nhân không cần hướng dẫn. Nó có thể đọc các interface và làm theo các mẫu.

Góc nhìn về Cơ sở hạ tầng

Những lần trích xuất này cũng làm sạch cách chúng tôi xử lý các tích hợp bên ngoài ở mức hạ tầng. Mỗi service có cấu hình riêng:

// config/services.php
'crm' => [
    'client_id' => env('CRM_CLIENT_ID'),
    'client_secret' => env('CRM_CLIENT_SECRET'),
    'sync_enabled' => env('CRM_SYNC_ENABLED', false),
    'realtime_sync' => env('CRM_REALTIME_SYNC', true),
    'queue' => env('CRM_SYNC_QUEUE', 'crm'),
],

Và các công việc (jobs) từng sống trong các trait được trích xuất thành các Laravel Jobs phù hợp chạy trên các hàng đợi (queues) chuyên dụng:

// CRM sync runs on its own Redis queue
// so it doesn't block order notifications
QUEUE_CONNECTION=redis
REDIS_QUEUE_DB=1

Queue worker trong Docker có thể được khởi chạy với một profile:

docker compose --profile queue up -d

Điều này có nghĩa là đồng bộ CRM có thể chậm, hay gặp lỗi, hoặc tạm thời bị hỏng mà không ảnh hưởng đến phần còn lại của ứng dụng. Hàng đợi sẽ tự động thử lại các công việc thất bại. Hàng đợi chuyên dụng đảm bảo sự cố CRM không làm tắc nghẽn các thông báo quan trọng.

Việc tách biệt quan tâm (separation of concerns) trong code tự nhiên dẫn đến sự tách biệt trong cơ sở hạ tầng. Đó là loại lợi ích kép bạn nhận được khi thực hiện tái cấu trúc đúng cách.

Câu chuyện về độ phủ kiểm thử (Test Coverage)

Trước khi trích xuất: Các controllers được kiểm thử, nhưng logic trait bên trong chúng chỉ được kiểm thử gián tiếp. Bạn không thể test "tin nhắn chat có được định dạng đúng không?" mà không thực hiện yêu cầu HTTP đến controller.

Sau khi trích xuất: Mỗi service có bài test riêng. Các bài test controller mock interface của service. Các bài test service xác minh logic thực tế.

// Trước: test thông báo chat nghĩa là test controller
$this->actingAs($admin)->post('/orders/1/approve')
    ->assertOk(); // ...và hy vọng thông báo đã được gửi?

// Sau: test service trực tiếp
$service = new ChatNotificationService($webhookUrl, $mockHttp);
$service->sendOrderNotification($order, 'Approved');
$mockHttp->assertSent(/* ... */);

Kim tự tháp bài test trở nên khỏe mạnh hơn. Nhiều unit test cho services, ít bài test tích hợp "cồng kềnh" cho controllers hơn.

Bài học kinh nghiệm

Traits là một dấu hiệu xấu (code smell) khi chúng chứa logic nghiệp vụ. Nếu bạn định sử dụng AI agents trên một codebase nhiều traits:

  1. Nhận diện các traits của bạn — đặc biệt là những cái có cuộc gọi HTTP bên ngoài, logic phức tạp hoặc trạng thái chia sẻ.
  2. Trích xuất chúng phía sau các interfaces — ưu tiên hợp đồng, một service tại một thời điểm.
  3. Khai báo interface trong service provider — để người tiêu dùng tiêm hợp đồng, không phải triển khai.
  4. Giữ trait cho đến khi tất cả người tiêu dùng được di chuyển — sau đó xóa nó.
  5. Chạy bộ test đầy đủ sau mỗi thay đổi — đây là điều không thể thương lượng.

Kết quả: Ranh giới sạch sẽ, services có thể kiểm thử, các triển khai có thể hoán đổi, và một codebase mà tác nhân AI thực sự có thể điều hướng.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗