Dependency inversion principle w SOLID

Dependency Inversion Principle (DIP) - to literka „D” w akronimie SOLID, czyli ostatnia z jej zasad, którą można przetłumaczyć jako Zasada odwrócenia zależności. Oryginalna definicja tej zasady, którą w 2002 roku Robert C. Martin (tzw. Uncle Bob) przedstawił w rozdziale swej książki „Agile Software Development, Principles, Patterns and Practices” brzmi:

A. Moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji.
B. Abstrakcje nie powinny zależeć od detali. Detale powinny zależeć od abstrakcji.

Mamy tutaj mowę o modułach wysokiego i niskiego poziomu, więc aby zrozumieć zasadę Dependency Inversion Principle, musimy sobie te pojęcia wyjaśnić. To, czy coś jest modułem wysokiego czy niskiego poziomu, zależy od przepływów sterowania w naszym procesie. Moduły niskiego poziomu są częścią procesu, który odbywa się w module wysokiego poziomu, tzn. moduły wysokiego poziomu wykorzystują moduły niskiego poziomu do realizacji swoich celów. Problem jednak jest często taki, że moduły wysokiego poziomu używają wprost konkretnej implementacji modułu niskiego poziomu, przez co są od nich zależne, tzn. zmiana w sposobie działania modułu niskiego poziomu może mieć wpływ na sposób działania modułu wysokiego poziomu, a to jest sytuacja niepożądana. Dlatego, według zasady DIP, moduły niskiego poziomu obudowujemy w abstrakcję i to od niej powinny być zależne oba te moduły. Drugą zasadą DIP jest to, że abstrakcja ta nie powinna zależeć od detali, czyli nie powinna zależeć od szczegółów implementacyjnych.

Dzięki stosowaniu tych dwóch reguł, nasz kod staje się bardzo elastyczny w warstwach np. infrastruktury, ponieważ możemy swobodnie wymieniać implementację naszych abstrakcji bez większego wpływu na nasz kod wysokiego poziomu.

Przykład użycia

Jako przykład użycia DIP weźmy uproszczony model dodawania zamówienia do sklepu internetowego przez użytkownika, a następnie wysłanie powiadomienia o jego utworzeniu. Na początek zdefiniujmy klasy naszego modelu.

Model użytkownika wygląda następująco i zawiera podstawowe informacje o użytkowniku takie jak imię, nazwisko, e-mail oraz numer telefonu:

class User {
    private string $name;
    private string $surname;
    private string $email;
    private string $phoneNumber;
    
    public function __construct(string $name, string $surname, string $email, string $phoneNumber)
    {
        $this->name = $name;
        $this->surname = $surname;
        $this->email = $email;
        $this->phoneNumber = $phoneNumber;
    }
    
    public function getName(): string
    {
        return $this->name;
    }
    
    public function getSurname(): string
    {
        return $this->surname;
    }
    
    public function getEmail(): string
    {
        return $this->email;
    }
    
    public function getPhoneNumber(): string
    {
        return $this->phoneNumber;
    }
}

Następnie implementacja modelu zamówienia — dla uproszczenia nasze zamówienie ma tylko pola numer zamówienia, wartość zamówienia i użytkownika, do którego zamówienie należy. Te trzy pola są wystarczające do przedstawienia przykładu.

class Order {
    private string $number;
    private float $value;
    private User $user;

    public function __construct(string $number, float $value, User $user)
    {
        $this->number = $number;
        $this->value = $value;
        $this->user = $user;
    }

    public function getNumber(): string
    {
        return $this->number;
    }
    
    public function getValue(): float
    {
        return $this->value;
    }

    public function getUser(): User
    {
        return $this->user;
    }
}

Kolejnym krokiem jest implementacja jakiegoś serwisu, którego odpowiedzialnością będzie dodawanie naszego zamówienia do magazynu danych, w naszym przykładzie będzie to baza danych MySQL. Wstępnie implementacja takiego serwisu mogłaby wyglądać tak:

 class OrderService {
   private Connection $connection;

    public function create(Order $order): void {
         $user = $order->getUser();
	     $this->connection->insert(
		'order', 
		[
		    'number' => $order->getNumber(), 
		    'value' => $order->getValue(), 
                'user_name' => $user->getName()‚
                'user_surname' => $user->getSurname(), 
                'user_email' => $user->getEmail()‚ 
                'user_phone_number’ => $user->getPhoneNumber()
		]
	  );

   }
 }

Taki serwis będzie działał poprawnie — tylko posiada jeden podstawowy problem, jeżeli założymy, że OrderService jest naszym serwisem domenowym wysokiego poziomu, to w tej implementacji zależy on bezpośrednio od niskopoziomowej implementacji zapisywania rekordu w bazie danych. Według założeń DIP, serwis wysokiego poziomu nie powinien zależeć bezpośrednio od niskopoziomowej implementacji zapisu do bazy danych. Jak rozwiązać ten problem? Musimy wydzielić zapis do bazy danych od abstrakcji tak, aby serwis domeny zależał od niej, a nie od konkretnej implementacji. Zaimplementujmy zatem najpierw abstrakcję odpowiedzialną za dodawanie zamówienia do modelu danych:

 interface OrderRepositoryInterface {
    public function add(Order order): void;
 }

Następnie utwórzmy implementację tej abstrakcji, która będzie zawierała konkretne rozwiązanie implementacyjne, czyli w naszym przypadku insert do bazy danych.

 class OrderRepositoryDAL implements OrderRepositoryInterface {
    private Connection $connection;

    public function add(Order $order) {
        $user = $order->getUser();
        $this->connection->insert(
		'order', 
		[
		    'number' => $order->getNumber(), 
		    'value' => $order->getValue(), 
                'user_name' => $user->getName()‚
                'user_surname' => $user->getSurname(), 
                'user_email' => $user->getEmail()‚ 
                'user_phone_number’ => $user->getPhoneNumber()
		]
	  );
    }
 }

I na koniec wstrzyknijmy interface do naszego serwisu OrderService:

 class OrderService {
    private OrderRepositoryInterface $orderRepository;

    public function __construct(OrderRepositoryInterface $orderRepository)
    {
        $this->orderRepository = $orderRepository;
    }

    public function create(Order $order): void {
        $this->orderRepository->add($order);
    }
 }

Teraz OrderService nie ma wiedzy o tym jakiej implementacji OrderRepositoryInterface używa oraz jaki sposób zapisu do magazynu danych się odbywa, ponieważ zależy on tylko bezpośrednio od abstrakcji.

Może się nasunąć pytanie: jakie plusy daje takie rozwiązanie? Wyobraźmy sobie, że chcielibyśmy zmapować nasz model Order na encję doctrine i dodawać ją do bazy danych za pomocą entityManager. W pierwszym przykładzie, aby to zrobić, musielibyśmy zmienić implementację OrderService oraz wszystkich innych miejsc, które używają dostępu do danych modelu Order (w dużym systemie może być ich wiele). W drugim przykładzie, który zachowuje zasadę DIP, wystarczy dodać kolejną implementację interfejsu OrderRepositoryInterface:

 class OrderDoctrineRepository {
    private EntityManagerInterface $entityManager;

    public function add(Order $order): void
    {
        $this->entityManager->persist($order);
        $this->entityManager->flush();
    }
 }

Następnie wystarczy dodać plik konfiguracyjny z mapowaniem modelu Order na encję doctrine oraz zmienić konfigurację wstrzykiwania zależności, tak aby jako OrderRepositoryInterface była wybrana implementacja OrderDoctrineRepository. Zauważmy że w żaden sposób nie musieliśmy dotykać naszego serwisu domenowego.

Kolejnym wymaganiem naszego przykładu jest wysłanie powiadomienia do użytkownika o poprawnym utworzeniu zamówienia. Powiadomienia mogą być wysyłane w różny sposób np. przez e-mail, wiadomość sms czy powiadomienie na stronie. W przeciwieństwie do poprzedniego przykładu z dodawaniem zamówienia do bazy, zacznijmy modelować proces od abstrakcji, tak aby był on zgodny z zasadą DIP. Daje nam to taką swobodę, że w momencie modelowania możemy jeszcze nie mieć wiedzy o tym jaką drogą będziemy wysyłać powiadomienia do użytkownika.

Na wstępie zaimplementujmy model wysyłanej wiadomości:

class Message {
   private User $user;
   private string $messageText;
   
   public function __construct(User $user, string $messageText)
   {
       $this->user = $user;
       $this->messageText = $messageText;
   }
   
   public function getUser(): User
   {
       return $this->user;
   }
    
   public function getMessageText(): string
   {
       return $this->messageText;
   }
}

Teraz potrzebujemy serwis, który wyśle wiadomość do użytkownika, ale tak jak wspominałem wcześniej, zaczynamy modelowanie od abstrakcji, dlatego zaimplementujmy interfejs tego serwisu:

interface MessageSenderInterface {
    public function send(Message $message): void               
 }

Potrzebujemy jeszcze klasy, która na podstawie obiektu zamówienia zbuduje obiekt Message i prześle go do MessageSenderInterface, możemy to zaimplementować w postaci Managera wysyłania powiadomień dla zamówienia:

 class NotificationManager {
     private MessageSenderInterface $messageSender;

     public function __construct(MessageSenderInterface $messageSender) {
        $this->messageSender = $messageSender;
     }

     public function notify(Order order): void {
        $this->messageSender->send(
            new Message(order->getUser(), sprintf("You order %s has succesfully created with value %s", order->getNumber(), $order->getValue())
        );
     }
 }

Pozostało tylko użyć NotificationManager w naszym OrderService, tak aby po dodaniu zamówienia wiadomość została wysłana do użytkownika.

 class OrderService {
    private OrderRepositoryInterface $orderRepository;
    private NotificationManager $notificationManager;

    public function __construct(OrderRepositoryInterface $orderRepository, NotificationManager $notificationManager)
    {
        $this->orderRepository = $orderRepository;
        $this->notificationManager = $notificationManager;
    }

    public function create(Order $order): void {
        $this->orderRepository->add($order);
        $this->notificationManager->notify($order);
    }
 }

Możemy zauważyć, że dzięki takiemu podejściu mamy wymodelowany cały proces wysyłania powiadomienia do użytkownika bez konieczności określania w jaki fizycznie sposób ta wiadomość zostanie dostarczona, gdyż klasy wysokiego poziomu nie mają o tym wiedzy. Po podjęciu tej decyzji wystarczy zaimplementować interfejs MessageSenderInterface odpowiednią klasą, przykładowo:

 class EmailMessageSender implements MessageSenderInterface {
        //Logika wysyłania wiadomości poprzez email
 }

 class SmsMessageSender implements MessageSenderInterface {
        //Logika wysyłania wiadomości poprzez SMS.
 }

Podsumowanie

Na podstawie przedstawionych przykładów można zauważyć, że stosowanie się do zasady Depedency Inversion Principle daje bardzo dużą zaletę w modelowaniu naszych procesów, ponieważ elementy wysoko- i niskopoziomowe tak naprawdę możemy implementować niezależnie, pod warunkiem zamodelowania poprawnej abstrakcji. Dzięki tej właśnie abstrakcji, nasze elementy niskopoziomowe są swobodnie wymiennie, a zmiana ich implementacji nie ma bezpośredniego wpływu na procesy wysokopoziomowe. Takie podejście ułatwia również testowanie naszej logiki, ponieważ łatwo jesteśmy w stanie dostarczyć zależności testowanej klasy, gdyż możemy je mockować na poziomie interfejsu. W nowoczesnych aplikacjach takie modelowanie ma bardzo duże znaczenie, ponieważ w momencie implementacji nie skupiamy się na szczegółach, takich jak zapis do bazy, pliku czy sposoby wysyłania powiadomień, a bardziej na naszych wymaganiach biznesowych i poprawnym zachowaniu naszego modelu domenowego. Dzięki temu nasza domena jest wymienna i decyzję o implementacji konkretnych rozwiązań infrastruktury możemy podejmować później, a implementację swobodnie wymienić w razie potrzeby.

Autorem tekstu jest Damian Kusek