All posts
Clean Architecture với Laravel 13
laravelclean architecture

Clean Architecture với Laravel 13

Tien Le H.'s avatarTien Le H.
Table of Contents13 sections

Chào các bạn, mình là Tiến – Engineering Manager tại Classmethod Danang và có khoảng 8 năm làm việc với Laravel Framework

Laravel nổi tiếng vì giúp developer viết code nhanh, ít boilerplate, và có gần như mọi thứ cần thiết ngay trong framework: Eloquent, Queue, Validation, Notification. Phần lớn dự án Laravel trưởng thành hiện nay đã không còn dừng ở việc nhồi mọi thứ vào Controller nữa, mà tổ chức theo mô hình quen thuộc: Controller gọi Service, Service gọi Repository, Repository thao tác trên Model. Đây là một bước tiến rõ rệt so với việc viết tất cả logic ngay trong Controller. Tuy nhiên, mô hình này vẫn còn những giới hạn nhất định mà Clean Architecture được sinh ra để giải quyết.

Bài viết này sẽ đi qua ba phần: bản chất của Clean Architecture, cách áp dụng nó vào cấu trúc thư mục của một dự án Laravel 13, và những đánh đổi cần cân nhắc trước khi quyết định dùng nó.

1. Giới hạn của mô hình Controller, Service, Repository, Model

Với mô hình Controller gọi Service, Service gọi Repository, Repository thao tác trên Model, trách nhiệm giữa các lớp đã được phân chia rõ ràng hơn nhiều so với việc viết tất cả trong Controller. Nhưng nếu quan sát kỹ hướng phụ thuộc thực tế trong code, mô hình này vẫn tồn tại một số điểm nghẽn sau:

  • Repository thường là class cụ thể, không phải interface: Service phụ thuộc thẳng vào một Eloquent Repository cụ thể (ví dụ OrderRepository gọi trực tiếp Order::query()), không thông qua bất kỳ interface trung gian nào. Điều này khiến Service, dù được đặt đúng tên, vẫn phụ thuộc vào chi tiết triển khai của tầng dữ liệu thay vì phụ thuộc vào một abstraction (Dependency Inversion Principle).

  • Model vừa là Eloquent Model, vừa gánh vai trò Domain Entity: Business rule (ví dụ "đơn hàng chỉ được huỷ trong vòng 24 giờ") thường được viết thành method ngay trên Eloquent Model hoặc nằm rải trong Service, trộn lẫn với các mối quan tâm thuần về persistence như accessor, mutator, relationship, scope.

  • Service vẫn gọi trực tiếp Facade của framework: Nhiều Service class gọi thẳng DB::, Cache::, Queue:: bên trong logic nghiệp vụ. Vì vậy, dù được tách riêng khỏi Controller, Service vẫn khó test độc lập mà không cần bootstrap một phần của Laravel hoặc chạm vào database thật.

  • Khó tái sử dụng nhất quán giữa nhiều điểm gọi: Khi cùng một nghiệp vụ cần được gọi từ nhiều nơi (Controller cho API, Artisan Command cho vận hành nội bộ, Queue Job cho xử lý nền), việc gọi Service không được chuẩn hoá theo một "đơn vị nghiệp vụ" rõ ràng dễ khiến Service phình to với nhiều trách nhiệm khác nhau, hoặc mỗi nơi gọi lại tự bổ sung thêm điều kiện nghiệp vụ riêng.

Gốc rễ của những điểm nghẽn này vẫn là hướng phụ thuộc (dependency direction): Service, dù nằm ở vị trí trung tâm về mặt tổ chức thư mục, trong code thực tế vẫn phụ thuộc ra ngoài vào Eloquent và các Facade của framework, thay vì phụ thuộc vào một tập interface trừu tượng do chính tầng nghiệp vụ định nghĩa. Quá là củ chuối…

2. Nguyên lý cốt lõi của Clean Architecture

Clean Architecture, được Robert C. Martin (Uncle Bob) hệ thống hoá, không phải một framework hay thư viện. Nó là một tập nguyên tắc tổ chức code theo các lớp đồng tâm, trong đó lớp trong cùng chứa quy tắc nghiệp vụ quan trọng nhất, còn lớp ngoài cùng chứa các chi tiết kỹ thuật dễ thay đổi nhất.

Bốn lớp kinh điển gồm:

  1. Entities: Đối tượng nghiệp vụ cốt lõi và các quy tắc doanh nghiệp tổng quát nhất, không phụ thuộc vào bất kỳ ứng dụng cụ thể nào.

  2. Use Cases (Application Business Rules): Quy tắc nghiệp vụ đặc thù của ứng dụng, điều phối luồng dữ liệu giữa Entities để thực hiện một tác vụ cụ thể, ví dụ "đặt hàng" hoặc "huỷ đăng ký".

  3. Interface Adapters: Chuyển đổi dữ liệu giữa Use Case và thế giới bên ngoài, bao gồm Controller, Presenter, và Gateway (thường là nơi định nghĩa Repository interface).

  4. Frameworks & Drivers: Lớp ngoài cùng, chứa các chi tiết kỹ thuật cụ thể: framework web, database, thư viện gửi email, hệ thống file.

Nguyên tắc quan trọng nhất chi phối toàn bộ mô hình là Dependency Rule: mã nguồn ở lớp ngoài được phép phụ thuộc vào lớp trong, nhưng không bao giờ được phép ngược lại. Lớp Entities và Use Case không được biết gì về Eloquent, HTTP request, hay Laravel.

Để làm được điều này khi lớp trong cần dữ liệu từ lớp ngoài (ví dụ Use Case cần đọc dữ liệu từ database), Clean Architecture áp dụng Dependency Inversion Principle: lớp trong định nghĩa một interface (contract), còn lớp ngoài viết class cụ thể implement interface đó. Nhờ vậy, mũi tên phụ thuộc trong code vẫn hướng vào trong, dù luồng dữ liệu thực tế đi ra ngoài.

3. Áp dụng vào cấu trúc dự án Laravel 13

Laravel 13 (yêu cầu tối thiểu PHP 8.3) kế thừa "slim skeleton" được giới thiệu từ Laravel 11: không còn app/Http/Kernel.php hay app/Console/Kernel.php, toàn bộ middleware, exception handling và routing được cấu hình tập trung trong bootstrap/app.php, còn danh sách Service Provider nằm gọn trong bootstrap/providers.php. Điểm này thực ra khá thuận lợi cho Clean Architecture, vì việc đăng ký binding giữa interface và implementation trở nên tường minh và tập trung hơn so với các phiên bản Laravel cũ.

3.1. Cấu trúc thư mục đề xuất

Cách ánh xạ bốn lớp của Clean Architecture vào cấu trúc trên như sau: Domain tương ứng lớp Entities, chứa PHP thuần, không có bất kỳ use Illuminate\... nào. Application tương ứng lớp Use Case, điều phối nghiệp vụ và chỉ phụ thuộc vào các interface trong Domain. InfrastructureHttp cùng đóng vai trò Interface Adapters và Frameworks & Drivers: Infrastructure chứa phần triển khai cụ thể bằng Eloquent, còn Http chứa Controller, Form Request và API Resource, tức là nơi tiếp nhận request và trả response.

3.2. Domain: Entity thuần, không phụ thuộc Laravel

<?php

namespace App\Domain\Order\Entities;

use App\Domain\Order\ValueObjects\Money;

final class Order
{
    private array $items = [];

    public function __construct(
        private readonly int $customerId,
        private string $status = 'pending',
    ) {}

    public function addItem(string $productId, int $quantity, Money $unitPrice): void
    {
        $this->items[] = compact('productId', 'quantity', 'unitPrice');
    }

    public function total(): Money
    {
        return array_reduce(
            $this->items,
            fn (Money $carry, array $item) => $carry->add($item['unitPrice']->multiply($item['quantity'])),
            Money::zero(),
        );
    }

    public function status(): string
    {
        return $this->status;
    }
}

3.3. Domain: Repository interface (Gateway)

<?php

namespace App\Domain\Order\Repositories;

use App\Domain\Order\Entities\Order;

interface OrderRepositoryInterface
{
    public function save(Order $order): int;

    public function findById(int $id): ?Order;
}

3.4. Application: Use Case

<?php

namespace App\Application\Order\UseCases;

use App\Application\Order\DTOs\PlaceOrderInput;
use App\Domain\Order\Entities\Order;
use App\Domain\Order\Exceptions\InsufficientStockException;
use App\Domain\Order\Repositories\OrderRepositoryInterface;
use App\Domain\Order\ValueObjects\Money;

final class PlaceOrderUseCase
{
    public function __construct(
        private readonly OrderRepositoryInterface $orders,
        private readonly StockCheckerInterface $stockChecker,
    ) {}

    public function handle(PlaceOrderInput $input): int
    {
        if (! $this->stockChecker->isAvailable($input->productId, $input->quantity)) {
            throw new InsufficientStockException($input->productId);
        }

        $order = new Order($input->customerId);
        $order->addItem($input->productId, $input->quantity, Money::fromFloat($input->unitPrice));

        return $this->orders->save($order);
    }
}

Lưu ý: PlaceOrderUseCase chỉ biết đến OrderRepositoryInterfaceStockCheckerInterface, hoàn toàn không biết Eloquent hay database nào đang được dùng phía sau. Đây chính là điểm cho phép bạn viết unit test cho use case này bằng cách mock hai interface trên, không cần chạm vào database thật.

3.5. Infrastructure: Triển khai bằng Eloquent

<?php

namespace App\Infrastructure\Persistence\Eloquent\Repositories;

use App\Domain\Order\Entities\Order;
use App\Domain\Order\Repositories\OrderRepositoryInterface;
use App\Infrastructure\Persistence\Eloquent\Models\OrderModel;

final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): int
    {
        $model = OrderModel::create([
            'customer_id' => $order->customerId(),
            'status' => $order->status(),
            'total' => $order->total()->amount(),
        ]);

        return $model->id;
    }

    public function findById(int $id): ?Order
    {
        $model = OrderModel::find($id);

        return $model ? $this->toEntity($model) : null;
    }

    private function toEntity(OrderModel $model): Order
    {
        // Ánh xạ (mapping) từ Eloquent Model sang Domain Entity
        return new Order($model->customer_id, $model->status);
    }
}

3.6. Đăng ký binding trong Laravel 13

Với slim skeleton của Laravel 13, ta tạo một Provider riêng cho tầng Domain và đăng ký nó trong bootstrap/providers.php, thay vì nhồi tất cả vào AppServiceProvider mặc định:

<?php
// app/Infrastructure/Providers/DomainServiceProvider.php

namespace App\Infrastructure\Providers;

use App\Domain\Order\Repositories\OrderRepositoryInterface;
use App\Infrastructure\Persistence\Eloquent\Repositories\EloquentOrderRepository;
use Illuminate\Support\ServiceProvider;

final class DomainServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);
    }
}
<?php
// bootstrap/providers.php

return [
    App\Providers\AppServiceProvider::class,
    App\Infrastructure\Providers\DomainServiceProvider::class,
];

3.7. Http: Controller lightweight, chỉ đảm nhiệm việc điều phối

<?php

namespace App\Http\Controllers\Api;

use App\Application\Order\DTOs\PlaceOrderInput;
use App\Application\Order\UseCases\PlaceOrderUseCase;
use App\Http\Controllers\Controller;
use App\Http\Requests\PlaceOrderRequest;
use Illuminate\Http\JsonResponse;

final class OrderController extends Controller
{
    public function __construct(private readonly PlaceOrderUseCase $placeOrder) {}

    public function store(PlaceOrderRequest $request): JsonResponse
    {
        $orderId = $this->placeOrder->handle(
            PlaceOrderInput::fromArray($request->validated())
        );

        return response()->json(['order_id' => $orderId], 201);
    }
}

Controller lúc này chỉ còn ba việc: nhận request đã được validate, gọi Use Case, và trả response. Toàn bộ quy tắc nghiệp vụ nằm ở DomainApplication, hoàn toàn độc lập với việc request đến từ REST API, một Artisan command, hay một queue job.

4. Case study: Luồng đặt hàng trong hệ thống e-commerce

Giả sử một dự án Laravel 13 phục vụ một sàn thương mại điện tử vừa cần expose API cho mobile app, vừa cần một CLI command để nhân viên vận hành đặt hàng thủ công qua terminal. Nếu Service hiện tại chỉ được viết để phục vụ riêng Controller (không có interface, phụ thuộc thẳng vào Eloquent Repository), việc tái sử dụng cho luồng CLI thường dẫn đến hai lựa chọn không lý tưởng: hoặc gọi thẳng Service đó từ Command (chấp nhận Command cũng gánh theo toàn bộ phụ thuộc Eloquent của Service), hoặc copy logic kiểm tra tồn kho và tính tổng tiền sang một nơi khác.

Với cấu trúc Clean Architecture ở trên, cả OrderController (phục vụ API) và một PlaceOrderCommand (Artisan command, phục vụ CLI) đều có thể gọi chung một PlaceOrderUseCase. Sự khác biệt duy nhất giữa hai nơi gọi là cách chúng thu thập input (từ JSON request hay từ tham số dòng lệnh) và cách chúng trình bày kết quả (JSON response hay dòng log ra terminal). Business logic, bao gồm quy tắc kiểm tra tồn kho và tính tổng đơn hàng, chỉ tồn tại đúng một chỗ.

Khi đội ngũ QA viết integration test cho toàn bộ luồng, họ vẫn test qua HTTP như bình thường. Nhưng khi developer muốn test riêng quy tắc "không cho đặt hàng nếu hết tồn kho", họ chỉ cần viết một unit test gọi thẳng PlaceOrderUseCase với một OrderRepositoryInterface giả lập (fake) và một StockCheckerInterface giả lập, không cần khởi động HTTP kernel, không cần database, thời gian chạy test giảm từ vài trăm mili giây xuống dưới một mili giây cho mỗi test case.

5. Đánh đổi cần cân nhắc

Clean Architecture không miễn phí. Trước khi áp dụng cho toàn bộ dự án, cần cân nhắc các điểm sau:

  • Boilerplate tăng lên đáng kể: Một thao tác CRUD đơn giản giờ đây có thể cần đến Entity, DTO, Use Case, Repository interface, và Eloquent Repository, thay vì chỉ một Controller và một Model như cách làm truyền thống của Laravel.

  • Chi phí ánh xạ (mapping): Việc chuyển đổi qua lại giữa Eloquent Model và Domain Entity tốn thêm code và một chút overhead runtime. Với ứng dụng cần tối ưu hiệu năng đọc dữ liệu ở mức cao, đây là điểm cần đo đạc kỹ.

  • Đường cong học tập: Không phải thành viên nào trong team cũng quen thuộc với Dependency Inversion hay ranh giới giữa các lớp. Nếu không thống nhất quy ước rõ ràng, cấu trúc thư mục "chuẩn Clean Architecture" có thể nhanh chóng biến thành một mớ hỗn loạn khó hiểu hơn cả cấu trúc MVC truyền thống.

  • Mất đi một phần "phép màu" của Laravel: Nhiều tính năng tiện lợi của Eloquent (accessor, mutator, scope, quan hệ eager loading) được thiết kế để dùng trực tiếp trên Model. Khi Domain Entity tách rời khỏi Eloquent Model, một số tiện ích này không còn dùng được trực tiếp trong Domain, buộc phải tái tạo lại logic tương đương ở tầng Application hoặc Infrastructure.

6. Khi nào nên dùng, khi nào không

Clean Architecture phù hợp khi dự án có business logic phức tạp và dự kiến sống lâu dài, khi có nhiều điểm vào khác nhau cho cùng một nghiệp vụ (REST API, GraphQL, CLI, queue consumer), hoặc khi team đặc biệt coi trọng khả năng test độc lập với framework và database.

Ngược lại, với một MVP cần ra mắt nhanh, một ứng dụng CRUD đơn giản không có nhiều quy tắc nghiệp vụ phức tạp, hoặc một team nhỏ chưa quen với các khái niệm Dependency Inversion, việc áp dụng toàn bộ Clean Architecture ngay từ đầu thường là over-engineering. Trong những trường hợp đó, giữ nguyên mô hình Controller, Service, Repository, Model hiện có, chỉ cần đảm bảo Repository không để lộ chi tiết Eloquent ra ngoài Service (ví dụ luôn trả về Collection hoặc mảng thay vì trả thẳng Eloquent Builder), thường đã là lựa chọn thực tế và đủ hiệu quả. Xem thêm tại bài viết này – Tối ưu kiến trúc 4 tầng Laravel

Một cách tiếp cận cân bằng mà nhiều team áp dụng là: giữ nguyên mô hình Controller, Service, Repository, Model đang có, và chỉ nâng cấp dần các module có business logic phức tạp nhất lên đầy đủ Clean Architecture (tách Repository thành interface và implementation, tách Domain Entity ra khỏi Eloquent Model, đổi Service thành Use Case với một trách nhiệm rõ ràng) khi module đó thực sự cần đến khả năng test độc lập hoặc khả năng thay đổi hạ tầng linh hoạt, thay vì áp dụng đồng loạt cho toàn bộ dự án ngay từ ngày đầu.