Laravel nâng cao

Laravel Service Repository Pattern: Cách triển khai đúng

Hướng dẫn chi tiết cách implement Service Repository Pattern trong Laravel - Khi nào nên dùng, cấu trúc folder, best practices và ví dụ thực tế.

newspaper

BlogDev Team

15 tháng 12, 2024
Laravel Service Repository Pattern: Cách triển khai đúng
Featured Image

Giới thiệu

Khi Laravel project phát triển lớn, Fat ControllersGod Models trở thành vấn đề nghiêm trọng. Service Repository Pattern là giải pháp phổ biến để:

  • Tách biệt business logic khỏi Controllers
  • Abstract database operations
  • Dễ test và maintain hơn

Bài viết này sẽ hướng dẫn cách implement đúng - không chỉ copy boilerplate code.

Khi nào cần Service Repository Pattern?

✅ Nên dùng khi:

  1. Project có >= 3 developers - Cần conventions rõ ràng
  2. Business logic phức tạp - Nhiều hơn CRUD đơn giản
  3. Nhiều data sources - API, database, cache
  4. Cần unit testing - Mock dependencies dễ dàng

❌ Không cần khi:

  1. MVP / Small projects - Over-engineering
  2. CRUD đơn giản - Laravel Eloquent là đủ
  3. Solo developer - Tự nhớ được code

Folder Structure

app/
├── Http/
│   └── Controllers/
│       └── Api/
│           └── ProductController.php
├── Services/
│   ├── ProductService.php
│   └── Contracts/
│       └── ProductServiceInterface.php
├── Repositories/
│   ├── ProductRepository.php
│   └── Contracts/
│       └── ProductRepositoryInterface.php
├── Models/
│   └── Product.php
└── Providers/
    └── RepositoryServiceProvider.php

Step-by-step Implementation

Step 1: Define Repository Interface

<?php
// app/Repositories/Contracts/ProductRepositoryInterface.php

namespace App\Repositories\Contracts;

use App\Models\Product;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;

interface ProductRepositoryInterface
{
    public function all(): Collection;
    
    public function paginate(int $perPage = 15): LengthAwarePaginator;
    
    public function find(int $id): ?Product;
    
    public function findOrFail(int $id): Product;
    
    public function create(array $data): Product;
    
    public function update(Product $product, array $data): Product;
    
    public function delete(Product $product): bool;
    
    public function findBySlug(string $slug): ?Product;
    
    public function getActiveProducts(): Collection;
}

Step 2: Implement Repository

<?php
// app/Repositories/ProductRepository.php

namespace App\Repositories;

use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;

class ProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        protected Product $model
    ) {}

    public function all(): Collection
    {
        return $this->model->all();
    }

    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return $this->model
            ->with(['category', 'images'])
            ->latest()
            ->paginate($perPage);
    }

    public function find(int $id): ?Product
    {
        return $this->model->find($id);
    }

    public function findOrFail(int $id): Product
    {
        return $this->model->findOrFail($id);
    }

    public function create(array $data): Product
    {
        return $this->model->create($data);
    }

    public function update(Product $product, array $data): Product
    {
        $product->update($data);
        return $product->fresh();
    }

    public function delete(Product $product): bool
    {
        return $product->delete();
    }

    public function findBySlug(string $slug): ?Product
    {
        return $this->model
            ->where('slug', $slug)
            ->with(['category', 'images', 'reviews'])
            ->first();
    }

    public function getActiveProducts(): Collection
    {
        return $this->model
            ->where('is_active', true)
            ->where('stock', '>', 0)
            ->get();
    }
}

Step 3: Define Service Interface

<?php
// app/Services/Contracts/ProductServiceInterface.php

namespace App\Services\Contracts;

use App\Models\Product;
use Illuminate\Http\UploadedFile;

interface ProductServiceInterface
{
    public function createProduct(array $data, ?UploadedFile $image = null): Product;
    
    public function updateProduct(Product $product, array $data): Product;
    
    public function deleteProduct(Product $product): void;
    
    public function updateStock(Product $product, int $quantity): void;
    
    public function applyDiscount(Product $product, float $percentage): Product;
}

Step 4: Implement Service

<?php
// app/Services/ProductService.php

namespace App\Services;

use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\Contracts\ProductServiceInterface;
use App\Exceptions\InsufficientStockException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class ProductService implements ProductServiceInterface
{
    public function __construct(
        protected ProductRepositoryInterface $productRepository
    ) {}

    public function createProduct(array $data, ?UploadedFile $image = null): Product
    {
        return DB::transaction(function () use ($data, $image) {
            // Generate slug
            $data['slug'] = $this->generateUniqueSlug($data['name']);
            
            // Handle image upload
            if ($image) {
                $data['image'] = $this->uploadImage($image);
            }
            
            // Create product
            $product = $this->productRepository->create($data);
            
            // Dispatch events
            event(new ProductCreated($product));
            
            return $product;
        });
    }

    public function updateProduct(Product $product, array $data): Product
    {
        return DB::transaction(function () use ($product, $data) {
            // Update slug if name changed
            if (isset($data['name']) && $data['name'] !== $product->name) {
                $data['slug'] = $this->generateUniqueSlug($data['name']);
            }
            
            return $this->productRepository->update($product, $data);
        });
    }

    public function deleteProduct(Product $product): void
    {
        DB::transaction(function () use ($product) {
            // Delete image
            if ($product->image) {
                Storage::disk('public')->delete($product->image);
            }
            
            // Soft delete related records if needed
            $product->reviews()->delete();
            
            // Delete product
            $this->productRepository->delete($product);
        });
    }

    public function updateStock(Product $product, int $quantity): void
    {
        $newStock = $product->stock + $quantity;
        
        if ($newStock < 0) {
            throw new InsufficientStockException(
                "Insufficient stock. Available: {$product->stock}, Requested: " . abs($quantity)
            );
        }
        
        $this->productRepository->update($product, ['stock' => $newStock]);
        
        // Check low stock alert
        if ($newStock <= 5) {
            event(new LowStockAlert($product));
        }
    }

    public function applyDiscount(Product $product, float $percentage): Product
    {
        $discountedPrice = $product->price * (1 - $percentage / 100);
        
        return $this->productRepository->update($product, [
            'discounted_price' => round($discountedPrice, 2),
            'discount_percentage' => $percentage,
        ]);
    }

    protected function generateUniqueSlug(string $name): string
    {
        $slug = Str::slug($name);
        $count = Product::where('slug', 'like', $slug . '%')->count();
        
        return $count ? "{$slug}-{$count}" : $slug;
    }

    protected function uploadImage(UploadedFile $image): string
    {
        return $image->store('products', 'public');
    }
}

Step 5: Bind Interfaces in Service Provider

<?php
// app/Providers/RepositoryServiceProvider.php

namespace App\Providers;

use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Repositories\ProductRepository;
use App\Services\Contracts\ProductServiceInterface;
use App\Services\ProductService;
use Illuminate\Support\ServiceProvider;

class RepositoryServiceProvider extends ServiceProvider
{
    public array $bindings = [
        ProductRepositoryInterface::class => ProductRepository::class,
        ProductServiceInterface::class => ProductService::class,
    ];

    public function register(): void
    {
        foreach ($this->bindings as $interface => $implementation) {
            $this->app->bind($interface, $implementation);
        }
    }
}

Đăng ký provider trong config/app.php:

'providers' => [
    // ...
    App\Providers\RepositoryServiceProvider::class,
],

Step 6: Use in Controller

<?php
// app/Http/Controllers/Api/ProductController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\Contracts\ProductServiceInterface;
use Illuminate\Http\JsonResponse;

class ProductController extends Controller
{
    public function __construct(
        protected ProductRepositoryInterface $productRepository,
        protected ProductServiceInterface $productService
    ) {}

    public function index(): JsonResponse
    {
        $products = $this->productRepository->paginate(20);
        
        return ProductResource::collection($products)->response();
    }

    public function store(StoreProductRequest $request): JsonResponse
    {
        $product = $this->productService->createProduct(
            $request->validated(),
            $request->file('image')
        );
        
        return ProductResource::make($product)
            ->response()
            ->setStatusCode(201);
    }

    public function show(Product $product): JsonResponse
    {
        return ProductResource::make($product)->response();
    }

    public function update(UpdateProductRequest $request, Product $product): JsonResponse
    {
        $product = $this->productService->updateProduct(
            $product,
            $request->validated()
        );
        
        return ProductResource::make($product)->response();
    }

    public function destroy(Product $product): JsonResponse
    {
        $this->productService->deleteProduct($product);
        
        return response()->json(null, 204);
    }
}

Testing

Unit Test cho Service

<?php
// tests/Unit/Services/ProductServiceTest.php

namespace Tests\Unit\Services;

use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use App\Services\ProductService;
use App\Exceptions\InsufficientStockException;
use Mockery;
use Tests\TestCase;

class ProductServiceTest extends TestCase
{
    protected ProductRepositoryInterface $mockRepository;
    protected ProductService $service;

    protected function setUp(): void
    {
        parent::setUp();
        
        $this->mockRepository = Mockery::mock(ProductRepositoryInterface::class);
        $this->service = new ProductService($this->mockRepository);
    }

    /** @test */
    public function it_updates_stock_correctly(): void
    {
        $product = new Product(['stock' => 10]);
        
        $this->mockRepository
            ->shouldReceive('update')
            ->once()
            ->with($product, ['stock' => 15])
            ->andReturn($product);
        
        $this->service->updateStock($product, 5);
    }

    /** @test */
    public function it_throws_exception_for_insufficient_stock(): void
    {
        $product = new Product(['stock' => 5]);
        
        $this->expectException(InsufficientStockException::class);
        
        $this->service->updateStock($product, -10);
    }
}

Common Mistakes

❌ Repository chứa business logic

// ❌ Bad - Business logic trong Repository
public function createWithDiscount(array $data, float $discount): Product
{
    $data['price'] = $data['price'] * (1 - $discount);
    return $this->model->create($data);
}

// ✅ Good - Repository chỉ data access
public function create(array $data): Product
{
    return $this->model->create($data);
}

❌ Service gọi Eloquent trực tiếp

// ❌ Bad - Bypass repository
public function getActiveProducts()
{
    return Product::where('is_active', true)->get();
}

// ✅ Good - Dùng repository
public function getActiveProducts()
{
    return $this->productRepository->getActiveProducts();
}

❌ Quá nhiều interfaces cho small projects

// ❌ Over-engineering - Không cần interface cho mọi thứ
interface ProductRepositoryInterface
interface CategoryRepositoryInterface
interface UserRepositoryInterface
// ... 50 interfaces cho CRUD đơn giản

// ✅ Consider: Base interface + specific methods
interface RepositoryInterface {
    public function all();
    public function find(int $id);
    public function create(array $data);
    // ...
}

Kết luận

Service Repository Pattern là powerful tool cho Laravel projects lớn, nhưng không phải silver bullet:

✅ Benefits:

  • Separation of concerns
  • Testability
  • Maintainability
  • Team collaboration

❌ Trade-offs:

  • More files, more boilerplate
  • Learning curve
  • Over-engineering risk

Rule of thumb: Nếu project có > 20 models và > 3 developers - strongly consider pattern này. Nếu không, Eloquent models + Form Requests có thể đủ.

history_edu Góc học tập & giải trí

Thử Thách Kiến Thức Lịch Sử?

Khám phá hàng trăm câu hỏi trắc nghiệm lịch sử thú vị tại HistoQuiz. Vừa học vừa chơi, nâng cao kiến thức ngay hôm nay!

Chơi Ngay arrow_forward