Decoupling business logic from controllers using a service layer

Often a controller contains a lot of business logic. The term “business logic” (or domain logic) is commonly used to describe the part of an application that handles the “business rules”. A business rule is intended to define an operation of the business. A simple business rule could be: “An URL needs to receive a notification when a new order is placed in the webshop”.

Ideally you want to keep your controllers “dumb” and thin. This means that it “should” not have a lot of knowledge of the application’s core logic. At Notive, the company I work at, we often use something we like to call “Services”.

The service layer

The service layer will help us abstract our logic. It encapsulates our application logic behind a (single) API. This helps us crafting a code base which is more maintainable.

As per this blog post, we will create a few very simple, yet effective service classes. In a real world application these classes would most likely be a bit more advanced.

Our application

We have set up the following controller which handles the logic after someone placed an order.

<?php

namespace App\Controllers;

class OrderController
{
    public function create()
    {
        $order = new Order();
        $order->amount = 100;
        $order->lines = [...];
        $order->reference = 'ORDER_001';

        $payment = new Payment();
        $payment->order = $order;
        $payment->amount = 100;
        $payment->description = "Payment for order {$order->reference}";

        $webhook = new Webhook();
        $webhook->endpoint = 'https://boer.dev';
        $webhook->data = [
            'model_id' => 14,
            'model_type' => 'order',
        ];
        $webhook->dispatch();
    }
}

We see three things happening here: we create an order, followed by the creation of a payment entry and ending with a webhook being fired to a certain endpoint. This works fine, but what if our business rules tells us that a customer may pay his order (invoice) at a later moment? We also want to fire webhooks for other operations within our application. This means we will have to repeat that exact same code snippets in different controllers.

Creating our services

To prevent duplicated code and/or controllers containing business logic, we will create our first service. Let’s name it OrderService.

<?php

namespace App\Services;

final class OrderService
{
    public function create(): Order
    {
        $order = new Order();
        $order->amount = 100;
        $order->lines = [...];
        $order->reference = 'ORDER_001';

        return $order;
    }
}

We just decoupled this business operation from our controller and added it to our service. By doing this, we achieved a few things:

  1. The order functionality is encapsulated in its own service
  2. We extracted business logic from our controller

Let’s apply this to our controller by injecting our OrderService.

<?php

namespace App\Controllers;

class OrderController
{
    /** @var \App\Services\OrderService */
    private $orderService;

    public function __construct(OrderService $orderService)
    {
        $this->orderService = $orderService;
    }

    public function create()
    {
        $order = $this->orderService->create();

        $payment = new Payment();
        $payment->order = $order;
        $payment->amount = 100;
        $payment->description = "Payment for order {$order->reference}";

        $webhook = new Webhook();
        $webhook->endpoint = 'https://boer.dev';
        $webhook->data = [
            'model_id' => 14,
            'model_type' => 'order',
        ];
        $webhook->dispatch();
    }
}

Our controller is a little bit lighter now, although, we still have logic within the controller. We can “decouple” the creation of the payment by creating a PaymentService.

<?php

namespace App\Services;

final class PaymentService
{
    public function create(Order $order): Payment
    {
        $payment = new Payment();
        $payment->order = $order;
        $payment->amount = 100;
        $payment->description = 'Our first payment';

        return $payment;
    }
}

At this point, we have two services: a service which handles the logic of creating our order, and a service which handles the creation of our payment.

Let’s apply our new PaymentService.

<?php

namespace App\Controllers;

class OrderController
{
    /** @var \App\Services\OrderService */
    private $orderService;
    /** @var \App\Services\PaymentService */
    private $paymentService;

    public function __construct(
        OrderService $orderService,
        PaymentService $paymentService
    ) {
        $this->orderService = $orderService;
        $this->paymentService = $paymentService;
    }

    public function create()
    {
        $order = $this->orderService->create();
        $payment = $this->paymentService->create($order);

        $webhook = new Webhook();
        $webhook->endpoint = 'https://boer.dev';
        $webhook->data = [
            'model_id' => 14,
            'model_type' => 'order',
        ];
        $webhook->dispatch();
    }
}

We are getting close now. The only business logic that the controller owns now, is the dispatching of a webhook. We can solve this by creating a WebhookService. This service will be a bit more “advanced”. Since we want to make sure this service is reusable, we do not want our WebhookService to know about the Order or Payment classes. For now, we will stay with a simple array of data, but ideally you want to be able to pass any kind of class which implements something like a WebhookableInterface.

<?php

namespace App\Services;

final class WebhookService
{
    private $httpClient;

    public function __construct(HttpClient $httpClient)
    {
        $this->httpClient = $httpClient;
    }

    public function prepareAndDispatch(string $endpoint, array $data)
    {
        $webhook = new Webhook();
        $webhook->endpoint = $endpoint;
        $webhook->data = $data;

        $this->dispatch($webhook);
    }

    public function dispatch(Webhook $webhook)
    {
        $this->httpClient->post(
            $webhook->endpoint,
            $webhook->data
        );
    }
}

Because we always want to fire a webhook when an order is placed, we will inject the WebhookService into our OrderService. Since we want to keep the create method of our OrderService “clean”, we also add a new method called prepareWebhook() to our OrderService.

<?php

namespace App\Services;

final class OrderService
{
    private $webhookService;

    public function __construct(WebhookService $webhookService)
    {
        $this->webhookService = $webhookService;
    }

    public function create(): Order
    {
        $order = new Order();
        $order->amount = 100;
        $order->lines = [...];
        $order->reference = 'ORDER_001';
        
        $this->prepareWebhook($order);

        return $order;
    }

    public function prepareWebhook(Order $order)
    {
        $endpoint = 'https://boer.dev';
        $data = [
            'model_id' => $order->id,
            'model_type' => get_class($order),
        ];

        $this->webhookService->prepareAndDispatch($endpoint, $data);
    }
}

Since we moved the webhook logic, we can now clean up our controller again. Let’s see what it will look like now.

<?php

namespace App\Controllers;

class OrderController
{
    /** @var \App\Services\OrderService */
    private $orderService;
    /** @var \App\Services\PaymentService */
    private $paymentService;

    public function __construct(
        OrderService $orderService,
        PaymentService $paymentService
    ) {
        $this->orderService = $orderService;
        $this->paymentService = $paymentService;
    }

    public function create()
    {
        $order = $this->orderService->create();
        $payment = $this->paymentService->create($order);
    }
}

And here we are! We have now created a controller which purpose is to solely pass and retrieve data, without applying logic itself.

In conclusion

Because we have abstracted the code from our controller into small separated classes, we have now achieved the following:

  • Reusability: we may call these service methods throughout our application
  • Single responsibility: the service methods are encapsulated with their own purpose
  • “Dumb” controllers: our controller itself does not contain business logic anymore, it simply combines/chains multiple services to achieve a certain result
  • Testability: because the classes are separated, each of them can be (unit) tested and replaced more easily

So next time you’re writing new functionality, and you think you want to reuse this; try to give the code it’s own place to live!

Also, there a plenty of methodologies that helps keeping your application clean and organised. Personally, the service layer is a concept that fits my needs a lot of the times. Needless to say that this pattern won’t always fit the project, but don’t be scared to try new things!