0

Service — a class containing business logic that doesn't belong in the model

Beginner5 min read·eng-16-013
interviewsolid

Concept

Service — a class containing business logic that doesn't naturally belong in a model, controller, or repository. It orchestrates operations, coordinates multiple domain objects, and encapsulates a specific business capability.

Why services exist: The classic MVC problem — controllers grow fat because they're the only place to put orchestration logic. Models should represent domain objects. Repositories handle data access. Where does the logic go? Services.

What a service does:

  • Coordinates multiple models/repositories.
  • Contains conditional business logic.
  • Dispatches events.
  • Calls external APIs (payment gateway, email service).
  • Wraps multiple operations in a transaction.

Service vs Repository:

  • Repository: Data access. OrderRepository::findPendingOrders().
  • Service: Business logic. OrderService::cancelExpiredOrders() — which calls repositories, sends emails, dispatches events.

Service vs Action class: A service is more general — it might have multiple related methods (OrderService::create(), OrderService::cancel(), OrderService::ship()). An action is a single-purpose class for one operation.

Thin controllers: Controllers should be thin — validate input, call a service, return response. Business logic is in the service, not the controller.

Laravel service conventions:

  • Single-responsibility services: PaymentService, NotificationService, ReportingService.
  • Registered in the service container.
  • Constructor-injected into controllers.

Code Example

php
<?php
// ❌ FAT CONTROLLER — business logic in the wrong place
class OrderController extends Controller
{
    public function store(CreateOrderRequest $request)
    {
        // This all belongs in a service!
        $order = \DB::transaction(function () use ($request) {
            $order = Order::create($request->validated());
            foreach ($request->items as $item) {
                $product = Product::lockForUpdate()->find($item['product_id']);
                if ($product->stock < $item['quantity']) throw new \DomainException('Insufficient stock');
                $product->decrement('stock', $item['quantity']);
                $order->items()->create($item);
            }
            return $order;
        });
        Mail::to($request->user())->send(new OrderConfirmation($order));
        event(new OrderPlaced($order));
        Cache::forget('user:' . $request->user()->id . ':order_count');
        return response()->json($order, 201);
    }
}

// ✅ THIN CONTROLLER + SERVICE
class OrderController extends Controller
{
    public function __construct(private readonly OrderService $orders) {}

    public function store(CreateOrderRequest $request): \Illuminate\Http\JsonResponse
    {
        $order = $this->orders->place($request->user(), $request->validated());
        return response()->json($order, 201);
    }
}

// ✅ SERVICE — all the orchestration logic
class OrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface   $orderRepository,
        private readonly ProductRepositoryInterface $productRepository,
    ) {}

    public function place(User $user, array $data): Order
    {
        return \DB::transaction(function () use ($user, $data) {
            $order = Order::create(['user_id' => $user->id, 'status' => 'pending', 'total' => 0]);
            $total = 0;

            foreach ($data['items'] as $item) {
                $product = $this->productRepository->findWithLock($item['product_id']);
                if ($product->stock < $item['quantity']) {
                    throw new \App\Exceptions\InsufficientStockException($product);
                }
                $product->decrement('stock', $item['quantity']);
                $order->items()->create([
                    'product_id' => $product->id,
                    'quantity'   => $item['quantity'],
                    'price'      => $product->price,
                ]);
                $total += $product->price * $item['quantity'];
            }

            $order->update(['total' => $total]);
            event(new \App\Events\OrderPlaced($order)); // dispatch event — listener sends email, etc.
            return $order->fresh(['items']);
        });
    }

    public function cancel(Order $order): void
    {
        if (!in_array($order->status, ['pending', 'confirmed'])) {
            throw new \DomainException("Cannot cancel a {$order->status} order");
        }
        \DB::transaction(function () use ($order) {
            foreach ($order->items as $item) {
                Product::where('id', $item->product_id)->increment('stock', $item->quantity);
            }
            $order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
            event(new \App\Events\OrderCancelled($order));
        });
    }
}