Введение
Стоит отметить, что сам по себе монолит не является антипаттерном и может отлично работать (и часто работает) при определенных условиях, обычно - когда он выбран осознанно. Но чаще всего монолитная архитектура в проекте не потому, что люди её выбрали, а потому что проект в неё естественно эволюционировал.
Прежде всего, давайте проясним, что мы имеем в виду под "декомпозицией монолита". Какая наша конечная цель? Какие могут быть бизнес-цели, побуждающие инженеров решать эту проблему?
Медленная скорость разработки. Это происходит из-за высокой связанности (coupling) кода, что включает в себя:
слишком запутанный код (есть важная разница между сложным (complex) и запутанным (complicated) кодом).
части кода слишком зависимы друг от друга, что приводит к более высокой вероятности возникновения конфликтов и повышает сложность построения надлежащего CI\CD процесса.
технологические сдвиги (например, обновление библиотек или миграция на другой фреймворк) почти невозможны из-за высоких временных затрат.
Монолит как единая точка отказа является бизнес-риском.
Горизонтальное масштабирование приложения становится более сложным.
Учитывая вышеизложенное, мы можем сказать, что нашей конечной целью является переход к более совершенной архитектуре приложения для увеличения скорости разработки и масштабируемости, а также снижения инфраструктурных рисков.
Теперь, когда мы определили проблему и цель, давайте разработаем высокоуровневый план.
Описание приложения
В качестве примера я буду использовать приложение для доставки еды. Для простоты оно не будет содержать какой-либо реальной бизнес-логики, но будет включать несколько обращений между сервисами и к базе данных. Суть приложения проще понять по следующей диаграмме:

Типичный 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
Высокоуровневый план
Теперь проясним то, что именно мы называем «связанным кодом» в контексте монолитной архитектуры:
Не смотря на то, что в нашем коде присутствует разделение на так называемые service classes, они взаимодействуют друг с другом посредством прямых вызовов методов.
Более того, даже если мы изменим способ взаимодействия сервисных классов друг с другом, мы все равно не сможем извлечь, потому что он использует классы других сервисов напрямую (через ключевое слово
use).Наконец, все service classes имеют доступ ко всем данным в базе, а значит, нет границ между ними с точки зрения данных.
В реальных приложениях, даже если мы концептуально понимаем необходимые изменения для разделения сервисов, для этого требуется много усилий. Поэтому процесс миграции должен быть итеративным и предсказуемым. С этой целью мы внедрим дополнительный шаг перед переходом к реальными сервисам - превратим наш монолит в модульный монолит.
Для этого введем определение модуля. Мы будем называть часть нашего кода модулем тогда и только тогда, когда он будет удовлетворять двум условиям:
Разделение кода. Модули не используют классы других модулей напрямую. Когда модулю необходимо вызвать другой модуль, используется service client.
Разделение данных. Каждый модуль использует собственную базу данных.
Разберем подробнее:
Модули не используют классы других модулей напрямую. Чтобы обеспечить соблюдение этого правила, мы будем использовать библиотеку под названием 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) каждой таблицы по очереди: в коде конфигурируется ещё одно подключение к базе данных, и запись происходит в оба хранилища, одновременно, а чтение - только из старой. Параллельно нам нужно разработать и запустить скрипт для переноса существующих данных из старой базы в новую. В этой статье мы не будем останавливаться на деталях реализации горячей миграции (это заслуживает отдельной статьи), но типичный алгоритм следующий:
Найти в коде места использования переносимой таблицы.
Для чтения оставить старое соединение.
Для записи (вставка, изменение, удаление) отправлять запросы в обе базы данных.
Тем временем разработать и запустить скрипт, который перенесет существующие данные из старой базы в новую.
После завершения миграции, зарелизить финальный PR, который будет использовать только новое соединение и для записи, и для чтения.
На последнем этапе можно удалить старую таблицу.
Моменты, которые следует учитывать:
Joins. Поскольку мы перемещаем таблицы в отдельные базы, операция
JOINмежду ними больше не будет возможна. Такие места придется переписывать в отдельные запросы.Foreign keys. СУБД больше не сможет обеспечить консистентность внешних ключей, поскольку таблицы находятся в отдельных базах данных. Если ваш продукт опирается на такую логику, ее придется реализовать на уровне кода приложения. Стоит отметить, что в highload проектах часто приходится отказываться от внешних ключей, поскольку у них есть свои проблемы.
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 мы получим отчет о зависимостях между нашими модулями:

Для разрешения данных зависимостей мы добавим 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, в котором будет проверяться отстутствие новых зависимостей.

Полный пример кода после этого шага можно увидеть здесь.
Переход от 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 диаграммы:

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

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

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

Шаги реализации включают в себя следующее:
Введение 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. Таким образом, мы имеем ряд преимуществ:
Producers отправляют сообщения в broker без необходимости указывать routing key и не зная, кто именно получит сообщение. Благодаря этому наши сообщения больше похожи на events, чем на background tasks, что уменьшает связанность сервисов.
Каждый сервис всегда получает все события и реагирует соответствующим образом. Ничего страшного, если сервис не умеет обрабатывать некоторые сообытия — сообщения можно игнорировать, а в случае необходимости, всегда можно дописать логику и начать реагировать на такие события.
Наконец, мы можем создать наше первое сообщение и изменить способ взаимодействия наших сервисов:
# 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 и принятия решений. Поэтому я предлагаю рассматривать эту статью не как руководство к действию, а как практический пример, показывающий, что этот процесс может (и должен) быть итеративным и предсказуемым. Самой большой проблемой таких архитектурных изменений часто является не само изменение, а обоснование и построение надлежащего процесса, который позволяет инженерам и другим вовлеченным сторонам верить в успех и сохранять фокус. Надеюсь, эта статья дает хотя бы отдаленное понимание как это может выглядить.
Спасибо за внимание и удачи!
