Service layer trong Laravel: khi controller bắt đầu quá béo

  • author-image

    Nguyễn Đình Cường

  • blog-tag
  • blog-comment 0 comment
  • created-date 09 Nov, 2026
blog-thumbnail

Controller dài hơn 200 dòng, vừa kiểm tra dữ liệu, vừa tính tiền, vừa gửi email, vừa ghi log? Đó là dấu hiệu bạn cần một lớp service. Bài này giúp bạn hiểu khi nào nên tách, tách như thế nào, và đừng tách quá đà.

Vấn đề của controller béo

Controller sinh ra để nhận request và trả response. Khi nhét cả nghiệp vụ vào đó, bạn gặp ba rắc rối: khó đọc vì nhiều việc lẫn lộn, khó tái sử dụng vì cùng logic có thể cần ở lệnh Artisan hoặc job, và khó kiểm thử vì phải giả lập cả HTTP chỉ để kiểm tra một phép tính.

Giải pháp phổ biến là chuyển nghiệp vụ sang một class thường (không kế thừa gì đặc biệt) gọi là service. Controller chỉ còn là lớp mỏng gọi service.

Ví dụ: đặt hàng

Trước khi tách, controller có thể trông như sau:

public function store(Request $request){    $data = $request->validate([...]);    $total = 0;    foreach ($data['items'] as $item) {        $product = Product::findOrFail($item['id']);        $total += $product->price * $item['qty'];    }    $order = Order::create(['user_id' => auth()->id(), 'total' => $total]);    Mail::to(auth()->user())->send(new OrderPlaced($order));    return $order;}

Sau khi tách, nghiệp vụ nằm trong service:

class PlaceOrderService{    public function __construct(private OrderNotifier $notifier) {}    public function handle(User $user, array $items): Order    {        return DB::transaction(function () use ($user, $items) {            $total = 0;            foreach ($items as $item) {                $product = Product::findOrFail($item['id']);                $total += $product->price * $item['qty'];            }            $order = Order::create(['user_id' => $user->id, 'total' => $total]);            $this->notifier->orderPlaced($order);            return $order;        });    }}

Và controller gọn lại thành vài dòng:

public function store(StoreOrderRequest $request, PlaceOrderService $service){    $order = $service->handle($request->user(), $request->validated('items'));    return response()->json($order, 201);}

Laravel tự tiêm PlaceOrderService (và cả OrderNotifier bên trong nó) qua container, bạn không phải tự khởi tạo.

Lợi ích thấy ngay

  • Tái sử dụng: cùng service dùng được ở controller, lệnh Artisan, job hay webhook.
  • Kiểm thử dễ: bạn gọi handle() trực tiếp với dữ liệu giả, không cần HTTP.
  • Giao dịch rõ ràng: toàn bộ thao tác nằm trong một nơi, dễ bọc trong transaction.
  • Đọc hiểu nhanh: tên service nói lên điều nghiệp vụ đang làm.

Đặt tên và tổ chức

Tên nên là động từ hoặc hành động nghiệp vụ như PlaceOrderService, RefundPayment, RegisterUser. Tránh những cái tên mơ hồ như OrderHelper hay OrderManager chứa đủ mọi thứ. Mỗi service nên làm một việc, có một phương thức công khai chính. Bạn có thể đặt chúng trong thư mục app/Services hoặc nhóm theo miền nghiệp vụ, như app/Orders/, tùy quy mô dự án.

Repository có cần không?

Nhiều tài liệu khuyên thêm lớp repository bọc Eloquent. Với phần lớn dự án Laravel, đây là lớp thừa: Eloquent vốn đã là một abstraction cho dữ liệu, và bọc thêm một lớp chỉ làm code dài ra. Bạn chỉ cần repository khi thật sự có nhu cầu đổi nguồn dữ liệu hoặc muốn tách hẳn nghiệp vụ khỏi framework. Hãy bắt đầu đơn giản, thêm khi nỗi đau xuất hiện chứ không phải để phòng xa.

Đừng lạm dụng

Không phải mọi thứ đều cần service. Một thao tác CRUD đơn giản, chỉ lưu và trả về, thì để ngay trong controller hoặc dùng model là hoàn toàn ổn. Một lỗi hay gặp là tạo ra hàng chục class chỉ chứa một dòng gọi sang model, thêm bước nhảy khi đọc code mà không thêm giá trị. Quy tắc thực dụng: tách khi logic có hơn một bước, được dùng ở hơn một nơi, hoặc cần kiểm thử riêng.

Kiểm thử service dễ dàng

Một trong những lý do chính để tách service là khả năng kiểm thử. Vì service nhận phụ thuộc qua hàm khởi tạo, bạn có thể thay thế phần gửi thông báo bằng đối tượng giả:

public function test_place_order_calculates_total(): void{    $notifier = Mockery::mock(OrderNotifier::class);    $notifier->shouldReceive('orderPlaced')->once();    $service = new PlaceOrderService($notifier);    $user = User::factory()->create();    $product = Product::factory()->create(['price' => 100000]);    $order = $service->handle($user, [['id' => $product->id, 'qty' => 3]]);    $this->assertEquals(300000, $order->total);}

Test này không gửi email thật, không cần HTTP, chạy nhanh và chỉ kiểm tra đúng logic tính tổng tiền. Đó là lợi ích cụ thể mà việc tách lớp mang lại.

Lộ trình tái cấu trúc an toàn

Đừng đập đi xây lại. Hãy chọn một action rối nhất, viết một feature test bao quanh hành vi hiện tại, rồi di chuyển logic sang service từng bước nhỏ, chạy test sau mỗi bước. Khi test vẫn xanh nghĩa là hành vi không đổi. Lặp lại với các action khác theo thời gian. Cách làm từ tốn này ít rủi ro hơn nhiều so với viết lại hàng loạt.

Một mẹo nữa là tránh để service phụ thuộc vào đối tượng Request. Nếu service nhận mảng hoặc đối tượng dữ liệu thuần, bạn có thể dùng nó từ lệnh Artisan hay job mà không cần giả lập HTTP, và ranh giới giữa tầng web với nghiệp vụ trở nên rõ ràng.

Kết luận

Service layer giúp controller mỏng, nghiệp vụ rõ ràng và dễ kiểm thử. Hãy tách dần từng đoạn logic đau đầu nhất thay vì viết lại cả dự án, và luôn hỏi mình: việc tách này có làm code dễ hiểu hơn không?

Nếu muốn luyện tập cùng bài tập thực tế, hãy ghé các khóa học tại codebem.com khi bạn sẵn sàng.

author_photo
Nguyễn Đình Cường

0 comment