Введение

Стоит отметить, что сам по себе монолит не является антипаттерном и может отлично работать (и часто работает) при определенных условиях, обычно - когда он выбран осознанно. Но чаще всего монолитная архитектура в проекте не потому, что люди её выбрали, а потому что проект в неё естественно эволюционировал.

Прежде всего, давайте проясним, что мы имеем в виду под "декомпозицией монолита". Какая наша конечная цель? Какие могут быть бизнес-цели, побуждающие инженеров решать эту проблему?

  1. Медленная скорость разработки. Это происходит из-за высокой связанности (coupling) кода, что включает в себя:

    1. слишком запутанный код (есть важная разница между сложным (complex) и запутанным (complicated) кодом).

    2. части кода слишком зависимы друг от друга, что приводит к более высокой вероятности возникновения конфликтов и повышает сложность построения надлежащего CI\CD процесса.

    3. технологические сдвиги (например, обновление библиотек или миграция на другой фреймворк) почти невозможны из-за высоких временных затрат.

  2. Монолит как единая точка отказа является бизнес-риском.

  3. Горизонтальное масштабирование приложения становится более сложным.

Учитывая вышеизложенное, мы можем сказать, что нашей конечной целью является переход к более совершенной архитектуре приложения для увеличения скорости разработки и масштабируемости, а также снижения инфраструктурных рисков.

Теперь, когда мы определили проблему и цель, давайте разработаем высокоуровневый план.

Описание приложения

В качестве примера я буду использовать приложение для доставки еды. Для простоты оно не будет содержать какой-либо реальной бизнес-логики, но будет включать несколько обращений между сервисами и к базе данных. Суть приложения проще понять по следующей диаграмме:

Приложение для доставки еды

Типичный flow следующий: customer делает заказ (create order), совершается обращение к restaurant service, и если заказ принят, то создается сущность доставки (delivery) и ожидается курьер. Когда курьер найден, он производит действия с доставкой через цепочку изменений статуса (change delivery status): courier_assigned -> courier_delivering -> successful. Таким образом, у нас есть три сервиса (customer, courier, restaurant), а также база данных, где хранятся orders и deliveries. В первоначальном состоянии, приложение выглядит как монолит с лишь минимальным разделением на сервисы посредством классов.

Ссылка на репозиторий с исходным кодом приложения: https://github.com/ilyachase/monolith-decoupling-example

Высокоуровневый план

Теперь проясним то, что именно мы называем «связанным кодом» в контексте монолитной архитектуры:

  1. Не смотря на то, что в нашем коде присутствует разделение на так называемые service classes, они взаимодействуют друг с другом посредством прямых вызовов методов.

  2. Более того, даже если мы изменим способ взаимодействия сервисных классов друг с другом, мы все равно не сможем извлечь, потому что он использует классы других сервисов напрямую (через ключевое слово use).

  3. Наконец, все service classes имеют доступ ко всем данным в базе, а значит, нет границ между ними с точки зрения данных.

В реальных приложениях, даже если мы концептуально понимаем необходимые изменения для разделения сервисов, для этого требуется много усилий. Поэтому процесс миграции должен быть итеративным и предсказуемым. С этой целью мы внедрим дополнительный шаг перед переходом к реальными сервисам - превратим наш монолит в модульный монолит.

Для этого введем определение модуля. Мы будем называть часть нашего кода модулем тогда и только тогда, когда он будет удовлетворять двум условиям:

  1. Разделение кода. Модули не используют классы других модулей напрямую. Когда модулю необходимо вызвать другой модуль, используется service client.

  2. Разделение данных. Каждый модуль использует собственную базу данных.

Разберем подробнее:

  • Модули не используют классы других модулей напрямую. Чтобы обеспечить соблюдение этого правила, мы будем использовать библиотеку под названием Deptrac. Это несложно: создается конфигурационный файл (по умолчанию deptrac.yaml), в нем определяются модули и далее используется исполняемый файл библиотеки для проверки соблюдения границ. Чаще всего, запуск этого файла добавляется в CI\CD (GitHub actions или что-то подобное). Важно, чтобы данная проверка была обязательной (required).

  • Когда одному модулю необходимо вызвать другой модуль, используется service client. Идея проста: рефакторинг монолита сразу в сервисную архитектуру - обычно слишком большой скачок, поэтому мы сначала подготовим код, используя sub-requests вместо реальных HTTP-запросов. Service client - это всего лишь вспомогательный класс, который формирует запрос, отправляет его в соответствующий модуль и возвращает ответ. Мы рассмотрим его реализацию чуть позже.

  • Каждый модуль использует собственную базу данных. В зависимости от вашего приложения вы можете выбрать разные паттерны баз данных, и «database per service» является лишь одним из них. Мы будем использовать его в нашем примере, потому что он один из самых распространенных.

Учитывая вышесказанное, давайте рассмотрим этапы миграции архитектуры:

Этапы миграции архитектуры

На последнем этапе мы не будем реализовывать полноценную event-driven архитектуру с event streams, bounded context models, outbox pattern и т.д. (поэтому, он под звездочкой). Однако, мы изменим способ коммуникации сервисов на асинхронные сообщения, т.к. это типично для приложений, претерпевающих такого рода эволюцию архитектуры, а значит, стоит рассмотреть это в реализации.

Реализация

Прежде чем мы начнем, не могу не подчеркнуть важность покрытия тестами. Конкретная реализация тестов выходит за рамки данной статьи, но в реальных приложениях первым шагом перед любыми архитектурными изменениями должно быть создание слоя тестов, который работают на near-HTTP уровне (например, Application tests в Symfony), или настоящие e2e тесты.

Переход от big ball of mud к modular monolith

Группировка файлов

Для начала, нам необходимо определить границы между частями нашего приложения. Они довольно очевидны в нашем примере (Customer, Restaurant и Courier), но на практике в реальных приложениях можно полагаться либо здравый смысл, либо на Domain Driven Design как более продвинутый подход. Не стоит делать слишком маленькие сервисы в начале, потому что это влечет за собой более высокий maintenance cost. Из статьи от Google:

We recommend that you create larger services instead of smaller services until you thoroughly understand the domain.

Когда границы определены, первым шагом будет группировка файлов в соответствующие директории, пока без распутывания зависимостей (as is). Давайте посмотрим на текущую файловую структуру:

src/
   Controller/
       CourierApiController.php
       CustomerApiController.php
   Dto/
       ChangeDeliveryStatusRequest.php
       CreateOrderRequest.php
   Entity/
       Delivery.php
       Order.php
       Restaurant.php
   Repository/
       DeliveryRepository.php
       OrderRepository.php
       RestaurantRepository.php
   Service/
       CourierService.php
       CustomerService.php
       RestaurantService.php

Мы создадим новый уровень директорий, репрезентирующий наши модули, а файлы, принадлежность к модулям которых не очевидна, переместим в каталог Common:

src/
   Customer/   <-- module level directory
       Controller/
           CustomerApiController.php
       Dto/
           CreateOrderRequest.php
       Entity/
           Order.php
       Repository/
           OrderRepository.php
       Service/
           CustomerService.php
   Restaurant/   <-- module level directory
       Entity/
           Restaurant.php
       Repository/
           RestaurantRepository.php
       Service/
           RestaurantService.php
   Courier/      <-- module level directory
       Controller/
           CourierApiController.php
       Dto/
           ChangeDeliveryStatusRequest.php
       Entity/
           Delivery.php
       Repository/
           DeliveryRepository.php
       Service/
           CourierService.php
   Common/
       Exception/
           EntityNotFoundException.php

Совет: если ваша IDE поддерживает PSR namespaces, она поможет вам исправлять ссылки на классы при их перемещении. Например, PhpStorm поддерживает его из коробки, если вы синхронизируете настройки IDE с Composer.

Также нам придется немного подправить конфиги Symfony для поддержки нашей новой структуры:

# config/packages/doctrine.yaml
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
-            App:
+            App\Courier:
+                is_bundle: false
+                dir: '%kernel.project_dir%/src/Courier/Entity'
+                prefix: 'App\Courier\Entity'
+                alias: App\Courier
+            App\Customer:
                is_bundle: false
-                dir: '%kernel.project_dir%/src/Entity'
-                prefix: 'App\Entity'
-                alias: App
+                dir: '%kernel.project_dir%/src/Customer/Entity'
+                prefix: 'App\Customer\Entity'
+                alias: App\Customer
+            App\Restaurant:
+                is_bundle: false
+                dir: '%kernel.project_dir%/src/Restaurant/Entity'
+                prefix: 'App\Restaurant\Entity'
+                alias: App\Restaurant

# config/routes.yaml
-controllers:
+courier_controllers:
    resource:
-        path: ../src/Controller/
-        namespace: App\Controller
+        path: ../src/Courier/Controller/
+        namespace: App\Courier\Controller
+    type: attribute
+customer_controllers:
+    resource:
+        path: ../src/Customer/Controller/
+        namespace: App\Customer\Controller
    type: attribute

Совет: в реальных приложениях этот шаг можно разделить на несколько небольших PR, поскольку технически он представляет собой простое перемещение файлов и изменение конфигурации. Таким образом, процесс станет более итеративным и предсказуемым.

Разделение баз данных

Когда файлы сгруппированы по директориями, следующим шагом является разделение базы данных. В случае нашего приложения довольно очевидно, каким будет разделение:

  • База customer, которая будет содержать таблицу order.

  • База restaurant, которая будет содержать таблицу restaurant.

  • База courier, которая будет содержать таблицу delivery.

В реальных приложениях зачастую не так просто определить границы баз данных - может потребоваться больше усилий и времени чтобы решить, какие таблицы относятся к каким модулям. Однако вне зависимости от масштаба приложения, техническая часть остается прежней — суть в том, чтобы выполнить так называемую «горячую миграцию» (hot migration) каждой таблицы по очереди: в коде конфигурируется ещё одно подключение к базе данных, и запись происходит в оба хранилища, одновременно, а чтение - только из старой. Параллельно нам нужно разработать и запустить скрипт для переноса существующих данных из старой базы в новую. В этой статье мы не будем останавливаться на деталях реализации горячей миграции (это заслуживает отдельной статьи), но типичный алгоритм следующий:

  1. Найти в коде места использования переносимой таблицы.

  2. Для чтения оставить старое соединение.

  3. Для записи (вставка, изменение, удаление) отправлять запросы в обе базы данных.

  4. Тем временем разработать и запустить скрипт, который перенесет существующие данные из старой базы в новую.

  5. После завершения миграции, зарелизить финальный PR, который будет использовать только новое соединение и для записи, и для чтения.

  6. На последнем этапе можно удалить старую таблицу.

Моменты, которые следует учитывать:

  1. Joins. Поскольку мы перемещаем таблицы в отдельные базы, операция JOIN между ними больше не будет возможна. Такие места придется переписывать в отдельные запросы.

  2. Foreign keys. СУБД больше не сможет обеспечить консистентность внешних ключей, поскольку таблицы находятся в отдельных базах данных. Если ваш продукт опирается на такую логику, ее придется реализовать на уровне кода приложения. Стоит отметить, что в highload проектах часто приходится отказываться от внешних ключей, поскольку у них есть свои проблемы.

  3. Transactions. После того, как таблицы разделены по нескольким базам данных, СУБД больше не сможет покрывать операции с такими таблицами транзакциями. В зависимости от бизнес-логики, такие транзакции придется либо удалить, либо переписывать, используя что-то вроде saga pattern.

Оставляя в стороне технические детали горячей миграции, давайте рассмотрим, как будет выглядеть разделение базы данных в нашем приложении. Во-первых, нам нужно ввести отдельные Entity managers и connections:

# config/packages/doctrine.yaml
doctrine:
    dbal:
-        url: '%env(resolve:DATABASE_URL)%'
-
-        # IMPORTANT: You MUST configure your server version,
-        # either here or in the DATABASE_URL env var (see .env file)
-        #server_version: '15'
-
-        profiling_collect_backtrace: '%kernel.debug%'
+        connections:
+            courier:
+                url: '%env(resolve:COURIER_DATABASE_URL)%'
+            customer:
+                url: '%env(resolve:CUSTOMER_DATABASE_URL)%'
+            restaurant:
+                url: '%env(resolve:RESTAURANT_DATABASE_URL)%'
    orm:
-        auto_generate_proxy_classes: true
-        enable_lazy_ghost_objects: true
-        report_fields_where_declared: true
-        validate_xml_mapping: true
-        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
-        auto_mapping: true
-        mappings:
-            App\Courier:
-                is_bundle: false
-                dir: '%kernel.project_dir%/src/Courier/Entity'
-                prefix: 'App\Courier\Entity'
-                alias: App\Courier
-            App\Customer:
-                is_bundle: false
-                dir: '%kernel.project_dir%/src/Customer/Entity'
-                prefix: 'App\Customer\Entity'
-                alias: App\Customer
-            App\Restaurant:
-                is_bundle: false
-                dir: '%kernel.project_dir%/src/Restaurant/Entity'
-                prefix: 'App\Restaurant\Entity'
-                alias: App\Restaurant
+        entity_managers:
+            courier:
+                report_fields_where_declared: true
+                validate_xml_mapping: true
+                connection: courier
+                mappings:
+                    App\Courier:
+                        is_bundle: false
+                        dir: '%kernel.project_dir%/src/Courier/Entity'
+                        prefix: 'App\Courier\Entity'
+                        alias: App\Courier
+            customer:
+                report_fields_where_declared: true
+                validate_xml_mapping: true
+                connection: customer
+                mappings:
+                    App\Customer:
+                        is_bundle: false
+                        dir: '%kernel.project_dir%/src/Customer/Entity'
+                        prefix: 'App\Customer\Entity'
+                        alias: App\Customer
+            restaurant:
+                report_fields_where_declared: true
+                validate_xml_mapping: true
+                connection: restaurant
+                mappings:
+                    App\Restaurant:
+                        is_bundle: false
+                        dir: '%kernel.project_dir%/src/Restaurant/Entity'
+                        prefix: 'App\Restaurant\Entity'
+                        alias: App\Restaurant

# .env
###> doctrine/doctrine-bundle ###
-DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/delivery_service?serverVersion=8.0.33&charset=utf8mb4"
+COURIER_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/courier_service?serverVersion=8.0.33&charset=utf8mb4"
+CUSTOMER_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/customer_service?serverVersion=8.0.33&charset=utf8mb4"
+RESTAURANT_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/restaurant_service?serverVersion=8.0.33&charset=utf8mb4"
###< doctrine/doctrine-bundle ###

# config/doctrine_migrations_courier.yaml
+migrations_paths:
+  'CourierMigrations': 'src/Courier/Migrations'

# config/doctrine_migrations_customer.yaml
+migrations_paths:
+  'CustomerMigrations': 'src/Customer/Migrations'

# config/doctrine_migrations_restaurant.yaml
+migrations_paths:
+  'RestaurantMigrations': 'src/Restaurant/Migrations'

# migrate command example: doctrine:migrations:migrate -n --em courier --configuration config/doctrine_migrations_courier.yaml

После этого, мы сможем использовать их по необходимости в коде, например:

# src/Customer/Service/CustomerService.php
    public function __construct(
        private RestaurantService $restaurantService,
        private CourierService $deliveryService,
-        private EntityManagerInterface $entityManager
+        private EntityManagerInterface $customerEntityManager
    ) {
    }
...
+        $this->customerEntityManager->persist($newOrder);
+        $this->customerEntityManager->flush();

Наконец, нам необходимо отрефакторить relations между entities, поскольку теперь они хранятся в отдельных базах данных и управляются разными entity managers. Самый простой способ это сделать - начать использовать значения напрямую, например:

# src/Courier/Entity/Delivery.php
namespace App\Courier\Entity;

-use App\Customer\Entity\Order;
use App\Courier\Repository\DeliveryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: DeliveryRepository::class)]
class Delivery
{
    public const STATUS_NEW = 'new';
@@ -28,9 +28,9 @@
    #[Groups(['api'])]
    private ?string $status = null;

-    #[ORM\OneToOne(cascade: ['persist', 'remove'])]
-    #[ORM\JoinColumn(nullable: false)]
-    private ?Order $RelatedOrder = null;
+    #[ORM\Column(name: 'related_order_id')]
+    private ?int $relatedOrderId = null;

    public function getId(): ?int
    {
@@ -49,14 +49,14 @@
        return $this;
    }

-    public function getRelatedOrder(): ?Order
+    public function getRelatedOrderId(): ?int
    {
-        return $this->RelatedOrder;
+        return $this->relatedOrderId;
    }

-    public function setRelatedOrder(Order $RelatedOrder): static
+    public function setRelatedOrderId(int $relatedOrderId): static
    {
-        $this->RelatedOrder = $RelatedOrder;
+        $this->relatedOrderId = $relatedOrderId;

        return $this;
    }

Как видите, разделение баз данных уже подталкивает наш код в направлении модульности. Это поможет нам в следующих шагах.

Полный пример кода после этого шага можно увидеть здесь.

Имплементация границ модулей

После того, как файлы перемещены в соответствующие директории, и база данных разделена, мы можем начать имплементировать более строгие границы между модулями. Для этого мы будем использовать библиотеку под названием Deptrac: определим в конфигурационном файле deptrac.yaml модули, а затем с помощью исполняемого файла vendor/bin/deptrac будем проверять соблюдение границ между ними. Это поможет нам в двух случаях:

  • Когда файлы только перемещены в соответствующие директории, мы можем определить модули в deptrac.yaml и запустить исполняемый файл deptrac, чтобы увидеть полный список зависимостей и спланировать работу по их рефакторингу.

  • Когда зависимости отрефакторены, мы можем закоммитить файл deptrac.yaml и добавить вызов исполняемого файла deptrac в CI\CD, чтобы предотвратить появление новых зависимостей.

Давайте посмотрим, как это выглядит на практике. Во-первых, определим наши модули в deptrac.yaml:

parameters:
 paths:
   - ./src
 layers:
   - name: Common
     collectors:
       - type: directory
         value: src/Common/.*
   - name: Courier
     collectors:
       - type: directory
         value: src/Courier/.*
   - name: Customer
     collectors:
       - type: directory
         value: src/Customer/.*
   - name: Restaurant
     collectors:
       - type: directory
         value: src/Restaurant/.*
 ruleset:
   Courier:
     - Common
   Customer:
     - Common
   Restaurant:
     - Common

После запуска vendor/bin/deptrac мы получим отчет о зависимостях между нашими модулями:

Отчет Deptrac

Для разрешения данных зависимостей мы добавим service clients и DTO в наш код. Это будет что-то вроде SDK для наших модулей, который мы поместим в папку Common. Смысл в том, чтобы иметь слой, где мы определяем способ взаимодействия между модулями, что в дальнейшем позволит нам легче менять этот самый способ. Для начала им будут являться прямые вызовы функций, а в дальнейшем станут HTTP-запросы и асинхронные сообщения. Также, при необходимости модуль Common позже можно будет перенести в отдельный репозиторий и использовать в качестве библиотеки Composer.

Для реализации service clients мы будем использовать фичу Symfony под названием sub-requests которая позволит нам отправлять запросы по существующим роутам внутри приложения без запросов по сети. Идея не уникальна - например, в Node.js существует аналогичный подход с использованием mcollina/fastify-undici-dispatcher.

Стоит отметить один нюанс - sub-requests имеют небольшой оверхед по сравнению с прямыми вызовами методов. Однако, чтобы он стал хоть сколько-нибудь заметным, нужно совершать sub-requests тысячи раз в цикле, а если мы вспомним, что эти вызовы позже станут HTTP-запросами, где оверхед будет намного больше, иногда даже полезно заранее заметить такие места и отрефакторить их.

Теперь давайте введём базовый класс для всех service clients:

# src/Common/Client/AbstractSymfonyControllerResolvingClient.php
<?php

declare(strict_types=1);

namespace App\Common\Client;

use App\Common\Exception\BadPayloadException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

abstract class AbstractSymfonyControllerResolvingClient
{
   public const IS_INTERNAL_REQUEST_ATTRIBUTE_KEY = 'is-internal-request';

   protected readonly Serializer $serializer;

   public function __construct(
       private readonly HttpKernelInterface $httpKernel,
   ) {
       $encoders = [new JsonEncoder()];
       $normalizers = [new ObjectNormalizer()];

       $this->serializer = new Serializer($normalizers, $encoders);
   }

   protected function sendServiceRequest(
       string $uri,
       array $query = [],
       array $requestBody = [],
       string $method = Request::METHOD_GET
   ): Response {
       foreach ([$query, $requestBody] as $payload) {
           $this->validatePayload($payload);
       }

       $request = new Request(
           query: $query,
           request: $requestBody,
           content: json_encode($requestBody, JSON_THROW_ON_ERROR),
       );

       $request->setMethod($method);
       $request->server->set('REQUEST_URI', $uri);
       $request->attributes->set(self::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY, true);

       return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
   }

   private function validatePayload($data): void
   {
       foreach ($data as $item) {
           if (is_array($item)) {
               $this->validatePayload($item);
           } elseif (!is_scalar($item) && !is_null($item)) {
               throw new BadPayloadException();
           }
       }
   }
}

Вы можете заметить метод validatePayload. Его цель - запретить передачу нескалярных аргументов внутри запроса. Несмотря на то, что в случае с sub-requests это ещё будет работать, полезно добавить эту проверку сейчас, чтобы обеспечить плавный переход к HTTP-запросам в дальнейшем.

Кроме того, поскольку мы добавляем новые роуты Symfony стандартным способом, они автоматически доступны для внешних запросов, чего мы не хотим. Для этого создадим специальный Event handler:

# src/Common/EventListener/HideInternalApiListener.php
<?php

declare(strict_types=1);

namespace App\Common\EventListener;

use App\Common\Client\AbstractSymfonyControllerResolvingClient;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

#[AsEventListener(event: 'kernel.request')]
class HideInternalApiListener
{
   public function onKernelRequest(RequestEvent $event): void
   {
       $serviceApiUrlPattern = '/service-';
       $comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern));
       if (0 !== $comparisonResult) {
           return;
       }

       $secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY);
       if (true !== $secretKey) {
           throw new NotFoundHttpException();
       }
   }
}

Он проверяет URI запроса и, если он начинается с /service- и не имеет специального request attribute, возвращает 404.

Теперь давайте добавим первый service client:

# src/Common/Client/RestaurantServiceClient.php
<?php

declare(strict_types=1);

namespace App\Common\Client;

use App\Common\Dto\Order;
use App\Common\Dto\Restaurant;
use RuntimeException;

class RestaurantServiceClient extends AbstractSymfonyControllerResolvingClient
{
   public function getRestaurant(int $restaurantId): ?Restaurant
   {
       $response = $this->sendServiceRequest('/service-restaurant/restaurants/'.$restaurantId);

       if (404 === $response->getStatusCode()) {
           return null;
       }

       if (200 !== $response->getStatusCode()) {
           throw new RuntimeException('Unexpected response code');
       }

       return $this->serializer->deserialize($response->getContent(), Restaurant::class, 'json');
   }

   public function acceptOrder(Order $orderDto): bool
   {
       $response = $this->sendServiceRequest(
           uri: '/service-restaurant/order/actions/accept',
           requestBody: $this->serializer->normalize($orderDto),
           method: 'POST'
       );

       if (200 !== $response->getStatusCode()) {
           throw new RuntimeException('Unexpected response code');
       }

       return $this->serializer->decode(data: $response->getContent(), format: 'json');
   }
}

И используйте его вместо прямого вызова сервиса:

# src/Customer/Service/CustomerService.php
readonly class CustomerService
{
    public function __construct(
-        private RestaurantService $restaurantService,
-        private CourierService $deliveryService,
-        private EntityManagerInterface $entityManager
+        private RestaurantServiceClient $restaurantServiceClient,
+        private CourierServiceClient $courierServiceClient,
+        private EntityManagerInterface $customerEntityManager
    ) {
    }

    public function createOrder(CreateOrderRequest $createOrderRequest): int
    {
-        if (!($restaurant = $this->restaurantService->getRestaurant($createOrderRequest->getRestaurantId()))) {
+        if (!($restaurant = $this->restaurantServiceClient->getRestaurant($createOrderRequest->getRestaurantId()))) {
            throw new EntityNotFoundException();
        }

        $newOrder = (new Order())
-            ->setRestaurant($restaurant)
+            ->setRestaurantId($restaurant->getId())
            ->setStatus(Order::STATUS_NEW);

-        if ($this->restaurantService->acceptOrder($newOrder)) {
+        $this->customerEntityManager->persist($newOrder);
+        $this->customerEntityManager->flush();
+
+        $orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());
+
+        if ($this->restaurantServiceClient->acceptOrder($orderDto)) {
            $newOrder->setStatus(Order::STATUS_ACCEPTED);
-            $newDelivery = $this->deliveryService->createDelivery($newOrder);
-            $newOrder->setDelivery($newDelivery);
+            $newDelivery = $this->courierServiceClient->createDelivery($orderDto);
+            $newOrder->setDeliveryId($newDelivery->getId());
        } else {
            $newOrder->setStatus(Order::STATUS_DECLINED);
        }

-        $this->entityManager->persist($newOrder);
-        $this->entityManager->flush();
+        $this->customerEntityManager->persist($newOrder);
+        $this->customerEntityManager->flush();

        return $newOrder->getId();
    }
}

Готово! Таким образом, мы сократили количество зависимостей из отчета Deptrac, с 15 до 11. Это пример итеративного разрешения зависимостей, при котором мы пошагово формируем границы между сервисами. Остальные зависимости в нашем приложении разрешаются тем же образом. В реальных приложениях обычно требуется больше сил на решение того, что поместить в папку Common — например, exceptions общего характера, DTO, helpers и т. д. Если есть сомнения, как правило, можно добавить чуть больше классов в этот неймспейс, а затем переместить в соответствующий сервис.

После того, как все зависимости отрефакторены, имеет смысл закоммитить файл конфигурации deptrac.yaml и добавить шаг в вашем CI\CD, в котором будет проверяться отстутствие новых зависимостей.

Чистый отчет Deptrac

Полный пример кода после этого шага можно увидеть здесь.

Переход от modular monolith к service-oriented architecture

После того, как модули разделены, переход к сервисам является довольно тривиальным. Ради примера я просто скопировал содержимое всего приложения в отдельные каталоги, затем удалил ненужные модули из исходного кода и исправил пару конфигов. Вот окончательная структура:

courier-service/  <-- Separate service level (complete Symfony application)
 src/
   Courier/      <-- Single module inside an application
 ...
customer-service/
 src/
   Customer/
 ...

deployment        <-- Infrastructure configurations (e.g. Nginx config)
restaurant-service/
 src/
   Restaurant/
 ...
.env
docker-compose.override.yml
docker-compose.yml
Dockerfile
LICENSE
Makefile
README.md

Нет смысла перечислять все мелкие изменения в файлах конфигурации, но выделю ключевые моменты. Для начала, конфигурация Nginx для такой структуры:

# deployment/nginx/default.nginx
map $request_uri $upstream {
   default       invalid;
   ~^/api/customer/   customer-service;
   ~^/api/courier/   courier-service;
   ~^/api/restaurant/   restaurant-service;
}

upstream customer-service {
   server customer-service:9000;
}

upstream courier-service {
   server courier-service:9000;
}

upstream restaurant-service {
   server restaurant-service:9000;
}

server {
   listen 80 default_server;
   root /usr/share/app/$upstream/public;

   location /api/ {
       try_files $uri /index.php$is_args$args;
   }

   location ~ ^/index\.php(/|$) {
       fastcgi_pass $upstream;
       fastcgi_split_path_info ^(.+\.php)(/.*)$;
       include fastcgi_params;

       fastcgi_param SCRIPT_FILENAME /usr/share/app/public$fastcgi_script_name;
       fastcgi_param DOCUMENT_ROOT /usr/share/app/public;

       internal;
   }

   location ~ \.php$ {
       return 404;
   }

   error_log /var/log/nginx/error.log;
   access_log /var/log/nginx/access.log;
}

Вы можете увидеть отдельные upstreams для каждого сервиса, а также блок выбора upstream на основе URI запроса.

Во-вторых, поскольку мы переходим от sub-requests к реальным HTTP-запросам, нам необходимо добавить symfony/http-client в наши сервисы, а затем скорректировать базовый класс service clients, сами service clients и контроллер API сервиса:

# restaurant-service/src/Common/Client/AbstractSymfonyControllerResolvingClient.php
-abstract class AbstractSymfonyControllerResolvingClient
+abstract class AbstractHttpClient
{
-    public const IS_INTERNAL_REQUEST_ATTRIBUTE_KEY = 'is-internal-request';
-
    protected readonly Serializer $serializer;

    public function __construct(
-        private readonly HttpKernelInterface $httpKernel,
+        private readonly HttpClientInterface $client,
+        #[Autowire('%api.secret.key%')]
+        private readonly string $apiSecretKey,
    ) {
        $encoders = [new JsonEncoder()];
        $normalizers = [new ObjectNormalizer()];
@@ -32,22 +33,23 @@
        array $query = [],
        array $requestBody = [],
        string $method = Request::METHOD_GET
-    ): Response {
+    ): ResponseInterface {
        foreach ([$query, $requestBody] as $payload) {
            $this->validatePayload($payload);
        }

-        $request = new Request(
-            query: $query,
-            request: $requestBody,
-            content: json_encode($requestBody, JSON_THROW_ON_ERROR),
+        return $this->client->request(
+            $method,
+            'http://nginx/api/'.$this->getServiceName().$uri,
+            [
+                'query' => $query,
+                'body' => $this->serializer->serialize($requestBody, JsonEncoder::FORMAT),
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'X-Api-Secret' => $this->apiSecretKey,
+                ],
+            ]
        );
-
-        $request->setMethod($method);
-        $request->server->set('REQUEST_URI', $uri);
-        $request->attributes->set(self::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY, true);
-
-        return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
    }

    private function validatePayload($data): void
@@ -60,4 +62,6 @@
            }
        }
    }
+
+    abstract protected function getServiceName(): string;
}

# courier-service/src/Common/Client/CustomerServiceClient.php
-class CustomerServiceClient extends AbstractSymfonyControllerResolvingClient
+class CustomerServiceClient extends AbstractHttpClient
{
    public function changeOrderStatus(int $orderId, string $newOrderStatus): void
    {
@@ -23,4 +23,9 @@
            throw new RuntimeException('Unexpected response code');
        }
    }
+
+    protected function getServiceName(): string
+    {
+        return 'customer';
+    }
}

# customer-service/src/Customer/Controller/ServiceApiController.php
+#[Route('/api/customer')]
class ServiceApiController extends AbstractController
{
    #[Route('/service-customer/orders', methods: 'POST')]

Поскольку мы ушли от sub-requests, то больше не можем использовать request attributes Symfony для защиты наших внутренних запросов. Необходим какой-то другой механизм. Для простоты я добавил секретный ключ, который отправляется вместе с запросом, а затем проверяется в том же HideInternalApiListener:

# courier-service/config/services.yaml
parameters:
+    api.secret.key: '%env(API_SECRET_KEY)%'

# courier-service/.env
+API_SECRET_KEY=539afcb6-1897-4204-a552-f2c7b8fc35d2

# customer-service/src/Common/EventListener/HideInternalApiListener.php
#[AsEventListener(event: 'kernel.request')]
-class HideInternalApiListener
+readonly class HideInternalApiListener
{
+    public function __construct(
+        #[Autowire('%api.secret.key%')]
+        private string $apiSecretKey,
+    ) {
+    }
+
    public function onKernelRequest(RequestEvent $event): void
    {
-        $serviceApiUrlPattern = '/service-';
+        $serviceApiUrlPattern = '/api/customer/service-customer/';
        $comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern));
        if (0 !== $comparisonResult) {
            return;
        }

-        $secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY);
-        if (true !== $secretKey) {
+        $apiSecret = $event->getRequest()->headers->get('X-Api-Secret');
+        if ($this->apiSecretKey !== $apiSecret) {
            throw new NotFoundHttpException();
        }
    }

Конечно, это не production-ready код, но достаточно в качестве примера. Лучшим подходом была бы реализация JWT для аутентификации внутренних запросов.

Полный пример кода после этого шага можно увидеть здесь.

Переход от service-oriented к event-driven architecture

В этой части мы изменим нашу архитектуру, чтобы она стала ближе к event-driven. Я специально использую такую формулировку, потому как уже говорилось раньше, мы не будем сильно углубляться в данную архитектуру (это выходит за рамки статьи). Вместо этого мы сосредоточимся на практических изменениях, необходимых для перехода от прямых вызовов service API к асинхронным сообщениям.

Необходимые изменения станут более понятными, если мы взглянем на следующие sequence диаграммы:

Create order action: синхронное взаимодействие

Этот flow мы превратим в следующую (я выделил места, которые станут асинхронными):

Create order action: асинхронное взаимодействие

То же самое касается действия «Change delivery status»:

Change delivery status action: синхронное взаимодействие

Чтобы сделать межсервисную связь асинхронной в этом flow, требуется лишь небольшое изменение:

Change delivery status action: асинхронное взаимодействие

Шаги реализации включают в себя следующее:

  • Введение async broker и consumers в нашу инфраструктуру.

  • Создание message-классов в нашем неймспейсе Common и соответствующие им message handlers.

  • Использование message bus вместо service client для межсервисной коммуникации.

А теперь шаг за шагом. Сначала нам нужно установить компонент Symfony Messenger: composer require symfony/messenger symfony/amqp-messenger

Затем настроить его:

# other services have the same configuration except queue name and routing
framework:
 messenger:

   serializer:
     default_serializer: messenger.transport.symfony_serializer

   transports:
     async:
       dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
       options:
         queues:
           restaurant-service: ~

   buses:
     default.bus:
       default_middleware:
         enabled: true
         allow_no_handlers: true

Добавить async broker и consumers:

# docker-compose.yml
+  amqp-broker:
+    image: rabbitmq:3
+    restart: unless-stopped

...

+  courier-consumer:
+    build:
+      context: .
+    working_dir: /usr/share/app
+    restart: unless-stopped
+    command: bin/console messenger:consume async -vv
+    volumes:
+      - ./courier-service/:/usr/share/app

// consumers for the rest 2 services are added the same way
...

Существует несколько способов использования asynchronous brokers, но мы будем использовать fanout exchange (который создается Symfony по умолчанию, если не указано другое) и queue per service. Таким образом, мы имеем ряд преимуществ:

  1. Producers отправляют сообщения в broker без необходимости указывать routing key и не зная, кто именно получит сообщение. Благодаря этому наши сообщения больше похожи на events, чем на background tasks, что уменьшает связанность сервисов.

  2. Каждый сервис всегда получает все события и реагирует соответствующим образом. Ничего страшного, если сервис не умеет обрабатывать некоторые сообытия — сообщения можно игнорировать, а в случае необходимости, всегда можно дописать логику и начать реагировать на такие события.

Наконец, мы можем создать наше первое сообщение и изменить способ взаимодействия наших сервисов:

# customer-service/src/Common/Message/OrderCreated.php
<?php

declare(strict_types=1);

namespace App\Common\Message;

use App\Common\Dto\Order as OrderDto;

readonly class OrderCreated
{
   public function __construct(private OrderDto $order)
   {
   }

   public function getOrder(): OrderDto
   {
       return $this->order;
   }
}

// all messages should be part of the "Common" namespace of each service
// in our case, for the sake of the example, we just copy it
// but in real application, it can (and should) be a package

# restaurant-service/src/Restaurant/MessageHandler/OrderCreatedHandler.php
<?php

declare(strict_types=1);

namespace App\Restaurant\MessageHandler;

use App\Common\Message\OrderAccepted;
use App\Common\Message\OrderCreated;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsMessageHandler]
readonly class OrderCreatedHandler
{
   public function __construct(private MessageBusInterface $messageBus)
   {
   }

   public function __invoke(OrderCreated $message)
   {
       // for the sake of the example, let's assume for now that the order can always be served
       $this->messageBus->dispatch(new OrderAccepted($message->getOrder()));
       // alternatively, we could dispatch this instead based on our business logic:
       // $this->messageBus->dispatch(new OrderDeclined());
   }
}

И теперь мы изменим код и начать использовать сообщения вместо вызова service clients:

# customer-service/src/Customer/Service/CustomerService.php
namespace App\Customer\Service;

-use App\Common\Client\CourierServiceClient;
use App\Common\Client\RestaurantServiceClient;
use App\Common\Dto\Order as OrderDto;
use App\Common\Exception\EntityNotFoundException;
+use App\Common\Message\OrderCreated;
use App\Customer\Dto\CreateOrderRequest;
use App\Customer\Entity\Order;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Messenger\MessageBusInterface;

readonly class CustomerService
{
    public function __construct(
        private RestaurantServiceClient $restaurantServiceClient,
-        private CourierServiceClient $deliveryServiceClient,
-        private EntityManagerInterface $customerEntityManager
+        private EntityManagerInterface $customerEntityManager,
+        private MessageBusInterface $messageBus,
    ) {
    }

@@ -36,17 +37,11 @@

        $orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());

-        if ($this->restaurantServiceClient->acceptOrder($orderDto)) {
-            $newOrder->setStatus(Order::STATUS_ACCEPTED);
-            $newDelivery = $this->deliveryServiceClient->createDelivery($orderDto);
-            $newOrder->setDeliveryId($newDelivery->getId());
-        } else {
-            $newOrder->setStatus(Order::STATUS_DECLINED);
-        }
-
        $this->customerEntityManager->persist($newOrder);
        $this->customerEntityManager->flush();

+        $this->messageBus->dispatch(new OrderCreated($orderDto));
+
        return $newOrder;
    }

Готово! Остальные места взаимодействия изменяются тем же способом. После этого мы можем отправить запрос в эндпойнт «Create order» и наблюдать, как сообщения действительно отправляются всем сервисам:

Асинхронные сообщения, обрабатываемые сервисами

Также это видно в наших логах:

customer-service_1     | Matched route "app_customer_customerapi_createorder".
customer-service_1     | Request: "GET http://nginx/api/restaurant/service-restaurant/restaurants/2"
restaurant-service_1   | Matched route "app_restaurant_serviceapi_getrestaurant".
customer-service_1     | Sending message App\Common\Message\OrderCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
customer-consumer_1    | Received message App\Common\Message\OrderCreated
customer-consumer_1    | No handler for message App\Common\Message\OrderCreated
customer-consumer_1    | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
courier-consumer_1     | Received message App\Common\Message\OrderCreated
courier-consumer_1     | No handler for message App\Common\Message\
courier-consumer_1     | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
restaurant-consumer_1  | Received message App\Common\Message\OrderCreated
restaurant-consumer_1  | Sending message App\Common\Message\OrderAccepted with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
restaurant-consumer_1  | Message App\Common\Message\OrderCreated handled by App\Restaurant\MessageHandler\OrderCreatedHandler::__invoke
restaurant-consumer_1  | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
restaurant-consumer_1  | Received message App\Common\Message\OrderAccepted
restaurant-consumer_1  | No handler for message App\Common\Message\OrderAccepted
restaurant-consumer_1  | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).
customer-consumer_1    | Received message App\Common\Message\OrderAccepted
courier-consumer_1     | Received message App\Common\Message\OrderAccepted
customer-consumer_1    | Message App\Common\Message\OrderAccepted handled by App\Customer\MessageHandler\OrderAcceptedHandler::__invoke
customer-consumer_1    | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).
courier-consumer_1     | No handler for message App\Common\Message\DeliveryCreated
courier-consumer_1     | Message App\Common\Message\OrderAccepted handled by App\Courier\MessageHandler\OrderAcceptedHandler::__invoke
courier-consumer_1     | Sending message App\Common\Message\DeliveryCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
...

Кроме того, после всех изменений мы можем заметить, что как courier, так и restaurant сервису больше не нужны service clients, поскольку им не нужно напрямую взаимодействовать с другими службами. Наш код стал чуточку чище!

Полный пример кода после этого шага можно увидеть здесь.

Заключение

В этой статье мы рассмотрели трансформацию архитектуры приложения в несколько этапов. Начиная с монолита, переходя на модульный монолит, затем отдельные сервисы и заканчивая событийно-ориентированной архитектурой. Мы сосредоточились на технических деталях такого перехода.

Однако стоит отметить, что в реальных приложениях будет гораздо больше работы, больше edge cases и принятия решений. Поэтому я предлагаю рассматривать эту статью не как руководство к действию, а как практический пример, показывающий, что этот процесс может (и должен) быть итеративным и предсказуемым. Самой большой проблемой таких архитектурных изменений часто является не само изменение, а обоснование и построение надлежащего процесса, который позволяет инженерам и другим вовлеченным сторонам верить в успех и сохранять фокус. Надеюсь, эта статья дает хотя бы отдаленное понимание как это может выглядить.

Спасибо за внимание и удачи!