Kompozycja moim zdaniem jest jedną z najbardziej przydatnych technik w programowaniu. Tworzenie wirtualnych obiektów, zawierających w sobie inne obiekty pozwala na bardzo dokładne odwzorowanie ich odpowiedników w świecie rzeczywistym. Bardzo dobrym przykładem jest samochód. Składa się on m. in. z silnika (który zawiera w sobie tłoki, świece, wtryskiwacze, kolektory), zawieszenia (sprężyny, amortyzatory, wahacze) albo skrzyni biegów (zębatki, synchronizatory). Każdy obiekt może zostać wyjęty i zastosowany gdzie indziej bez dodatkowych modyfikacji, prawda?

Na straży tej właściwości stoi zasada DIP (dependency inversion principle). Mówi ona, że kod programu powinien być oparty na abstrakcjach, a nie konkretnych implementacjach. Moduły wysokiego poziomu (jak np. nasz samochód) nie powinny zależeć od modułów niskiego poziomu (tj. tłoki, wahacze czy świece). Innymi słowy, my jako kierowcy nie musimy wiedzieć, jakie świece mamy w silniku ani czy wahacz jest markowy, aby nim kierować. Po prostu wiemy, że ten konkretny samochód ma jakieś świece i jakieś wahacze, skoro jeździ, skręca i hamuje 😉

Aby jeszcze lepiej omówić tę zasadę, posłużmy się przykładem prostego systemu obsługującego zamówienia internetowe. Jego zadaniem będzie przyjmowanie zamówień od klienta, zapisywanie ich w bazie danych, a następnie przesyłanie temu klientowi powiadomienia o przyjęciu zamówienia.

Na potrzeby komunikacji z bazą danych (np. MySQL) utwórzmy stosowna klasę:

class MySQLOrderRepository
{
    public function save(array $order): void
    {
        echo "Zapisuję zamówienie do MySQL\n";
    }
}

Powyższa klasa ma jedną metodę przyjmującą parametry zamówienia w formie tablicy asocjacyjnej celem wprowadzenia ich do bazy danych.

Oraz klasę obsługującą powiadomienia e-mail:

class SmtpMailer
{
    public function sendEmail(string $to, string $msg): void
    {
        echo "Wysyłam e-mail przez SMTP do: $to\n";
    }
}

Powyżej wszystko wygląda w porządku, metoda sendEmail przyjmuje dwa argumenty: adres email oraz treść wiadomości.

Wykorzystajmy powyższe w klasie OrderService:

class OrderService
{
    private MySQLOrderRepository $repository;
    private SmtpMailer $mailer;

    public function __construct()
    {
        $this->repository = new MySQLOrderRepository();
        $this->mailer     = new SmtpMailer();
    }

    public function placeOrder(array $order): void
    {
        $this->repository->save($order);
        $this->mailer->sendEmail($order['email'], 'Dziękujemy za zamówienie!');
    }
}

Wywołajmy nowe zamówienie:

$service = new OrderService();
$service->placeOrder(['email' => 'jan@example.com']);

Nasz kod działa bez zarzutu, jednak warto postawić sobie dwa pytania:

  • Co jeśli chcemy w przyszłości wykorzystać nasz kod i napisać podobny system w oparciu o np. PostgreSQL?
  • Jak zaimplementować dodatkową funkcję np. powiadamianie poprzez SMS albo przez specjalną aplikacje?
  • Co jeśli nasz klient nie chce powiadomień email, a tylko SMS?

Implementacja każdej z tych powyższych funkcjonalności wymusza na nas modyfikację klasy OrderService (dodawanie nowych pól), ponadto musimy modyfikować metodę placeOrder, używać instrukcji warunkowych „if” lub „switch”.

Ponadto, przyjrzyjmy się zależnościom klasy OrderService, jest ona ściśle uzależniona od implementacji (klasy MySQLOrderRepository oraz SmtpMailer są klasami niższego poziomu), więc nasz kod narusza regułę DIP, która taką zależność wyklucza. Klasa OrderService nie dość, że zna konfigurację bazy danych, to jeszcze ma z góry narzucone metody powiadamiania klientów, nie zostawiając pola na jakiekolwiek preferencje użytkownika.

Postarajmy się zlikwidować zależność od niskopoziomowych implementacji, zastępując je abstrakcją (czyli naszymi ulubionymi interfejsami).

Zacznijmy od utworzenia interfejsów dotyczących baz danych (poniżej umieszczam interfejs wraz z implementującą je klasą, w praktyce oczywiście każda klasa i interfejs lądują w osobnych plikach .php):

interface OrderRepositoryInterface
{
    public function save(array $order): void;
}

class MySQLOrderRepository implements OrderRepositoryInterface
{
    public function save(array $order): void
    {
        echo "Zapisuję do MySQL\n";
    }
}

class PostgreSQLOrderRepository implements OrderRepositoryInterface
{
    public function save(array $order): void
    {
        echo "Zapisuję do PostgreSQL\n";
    }
}

Jak widzimy, powyższa abstrakcja pozwala nam w przyszłości „dorobić” (w zależności od potrzeb) nowe klasy do pracy z innymi silnikami baz danych (Oracle, MariaDB, Microsoft SQL, Firebird itp.). Zapiszmy jeszcze klasy obsługujące powiadomienia:

interface NotifierInterface
{
    public function notify(string $to, string $msg): void;
}

class SmtpNotifier implements NotifierInterface
{
    public function notify(string $to, string $msg): void
    {
        echo "E-mail do $to: $msg\n";
    }
}

class SmsNotifier implements NotifierInterface
{
    public function notify(string $to, string $msg): void
    {
        echo "SMS do $to: $msg\n";
    }
}

Podobnie jak wcześniej, mamy pełną swobodę w wyborze metody powiadomień (może to być np. dedykowana aplikacja albo wybrany komunikator internetowy).

Z obu powyższych konstrukcji wychodzi kolejna korzyść, którą widać w klasie OrderService:

class OrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface $repository,
        private readonly NotifierInterface         $notifier,
    ) {}

    public function placeOrder(array $order): void
    {
        $this->repository->save($order);
        $this->notifier->notify($order['email'], 'Dziękujemy!');
    }
}

Klasa OrderService nie wie, jakie mamy do dyspozycji silniki baz danych, ani jaką drogą wysłać powiadomienia. Jej konstruktor wykorzystuje jedynie abstrakcję, więc klient ma większą swobodę wyboru. Wywołajmy teraz nasz nowy kod:

$service = new OrderService(
    new MySQLOrderRepository(),  // ← swap na PostgreSQL = jedna linia
    new SmtpNotifier(),           // ← swap na SmsNotifier = jedna linia
);
$service->placeOrder(['email' => 'jan@example.com']);

Klient decyduje, jaki silnik bazy danych chce wykorzystać oraz w jaki sposób przesłać powiadomienia. Zjawisko to nazywamy wstrzykiwaniem zależności – klasa OrderService sama z siebie jest „neutralna”, zaś wybór konkretnej implementacji pozostaje po stronie klienta.

Powyższy układ ma jeszcze jedną dobra cechę – testowalność. Wczesniej aby przetestować OrderService, potrzebowaliśmy prawdziwej bazy danych i działającego protokołu SMTP. teraz wystarczą klasy testowe bez konkretnych implementacji. Przykładowy test:

class InMemoryOrderRepository implements OrderRepositoryInterface
{
    public array $saved = [];
    public function save(array $order): void { $this->saved[] = $order; }
}

class NullNotifier implements NotifierInterface
{
    public function notify(string $to, string $msg): void {}  // ← nic nie robi
}

$repo    = new InMemoryOrderRepository();
$service = new OrderService($repo, new NullNotifier());
$service->placeOrder(['email' => 'test@test.com']);

Nie należy nigdy pozwolić, aby nasza główna logika biznesowa zależała od konkretnych narzędzi – programujemy do interfejsów (abstrakcji), a szczegóły implementacyjne wstrzykujemy z zewnątrz. Dzięki temu, nasze aplikacje staną się modułowe jak klocki LEGO i bajecznie proste w testowaniu.