Приветствую всех неравнодушных! Я являюсь руководителем разработки в компании DD Planet, и сегодня, наконец-то, дошли руки написать продолжение статьи
Во второй части статьи мы рассмотрим, как с помощью DDD структурировать домены, выстроить иерархию сервисов, настроить зависимости и внедрить DI-подход.
Продолжаем переводить архитектуру на DDD, и первым делом мы регистрируем пространство имен в файле init.php.

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

2 вариант. Автор выкладывает всю логику первого слоя на корневом уровне. Таким образом, первый слой будет состоять из наибольшего количества директорий.

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

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

Какой напрашивается вывод? Нет правильной схемы — есть лишь иерархия, основанная на логике проекта.
Создадим нашу основную архитектуру
local
App
Application — сам Битрикс является этим слоем. Нам он не понадобится.Domains — сюда складываем логику проекта.
Infrastructure — тут у нас будут лежать сервисы для интеграций.
Shared (Lib) — тут мы будем складывать сервисы, общие как для проекта, так и легко переносимые на другие проекты, и всякие хелперы, не завязанные на бизнес-логику.
Сервисный подход:
Для упрощения дальнейшей поддержки и доработок нужно сделать так, чтобы все разработчики действовали по аналогии, довольно сильно ограничивая свою фантазию относительно структуры, но не ограничиваясь в возможностях.
Таким образом, нам понадобится сервисный подход, который позволит одинаково обращаться к совершенно разным системам, а именно:
Layer\ServiceName\ServiceNameService::create($serviceId)->module()->methods()
Пример:
App\Domains\Order\OrderService::create()->repository()->getElementById($id);
App\Infrastructure\Http\HttpService::create(HTTPEnums::GUZZLE->value)->client()->send(array $body, Url $url)
Практика
1. В первую очередь смерджим наш composer.json в папке local с тем, который у нас в ядре, при помощи merge-plugin, чтобы не лезть в папку bitrix с доработками.
{
 "version": "1.0.0",
 "name": "name",
 "description": "description",
 "authors": [
   {
     "name": "name",
     "email": "email"
   }
 ],
 "extra": {
   "installer-paths": {
     "modules/{$name}/": [
       "type:bitrix-module"
     ]
   },
   "merge-plugin": {
     "require": [
       "../bitrix/composer-bx.json"
     ]
   }
 },
 "require": {
   "php": ">=8.2",
   "psr/container": "2.0",
   "psr/http-client": "^1.0",
   "psr/http-message": "^1.0",
   "psr/log": "^3.0",
   "...": "...",
 },
 "config": {
   "vendor-dir": "../bitrix/vendor",
   "allow-plugins": {
     "composer/installers": true,
     "php-http/discovery": true
   }
 },
 "require-dev": {
   "phpunit/phpunit": "11.3.0"
 },
 "scripts": {
   "test": "phpunit"
 }
}Затем зарегистрируем нашу архитектуру, используя собранный набор вендоров в файле init.php, подключение классов будет автоматически, согласно PSR-4:
use Bitrix\Main\Application;
use Bitrix\Main\Loader;
require(Application::getDocumentRoot() . "/bitrix/vendor/autoload.php");
/*****
* Подключение архитектурных классов.
*****/
try {
   Loader::registerNamespace(
       "App",
       Loader::getDocumentRoot() . "/local/App"
   );
} catch (LoaderException $e) {
	LoggerFacade::app()->error('Error include namespace: ' . $e->getMessage());
throw new \Exception($e->getMessage());
}Слой домена: тут каждый домен будет являть из себя полный набор необходимых ему для жизни функций, чтобы в дальнейшем можно было легко начать распил на микросервисы.

Каждый доменный сервис, для единого подхода с другими слоями, в которых подключение сервисов идет через паттерн «абстрактная фабрика», будет подключаться через паттерн «простая фабрика» и через нее возвращать сам себя.
Создаем интерфейс для сервисов домена:
<?php
namespace App\Domains\Contracts;
interface BaseServiceInterface
{
   /**
    * Method create service by key.
    * @return mixed
    */
   public static function create(): self;И применяем к нашему классу:
<?php
namespace App\Domains\Order\Services;
use App\Domains\Contracts\BaseServiceInterface;
use App\Domains\Order\Services\Exchange\ExchangeService;
use App\Domains\Order\Services\Repository\OrderRepository;
use App\Shared\DI\Container;
use Bitrix\Main\ArgumentException;
readonly class OrderService implements BaseServiceInterface
{
   /**
    * @throws ArgumentException
    */
   public static function create(): self
   {
       return (new Container())->get(self::class);
   }
   public function __construct(
       private OrderRepository $repository,
       private ExchangeService $exchangeService
   )
   {
   }
   public function repository(): OrderRepository
   {
       return $this->repository;
   }
   public function exchange(): ExchangeService
   {
       return $this->exchangeService;
   }
}Теперь мы можем обратиться к сервису и вызвать его дочерние классы, которые мы в него внедрили через паттерн DI
public function __construct(
       private OrderRepository $repository,
       private ExchangeService $exchangeService
   )Так, мы можем получить доступ к методу по пути OrderService::create()->repository()->getDealRepositoryByDealId($id);

Но если заметили, в методе create() используется какой-то Container()? Так вот, если мы вернем DI-инъекцию классов, то они будут требовать объявления передаваемых объектов, и нам придется прописывать инициализацию для всей структуры в каждой зависимости, на каждом уровне, что очень кропотливо.
return new self(new OrderRepository(), new ExchangeService());
Если брать symfony и laravel, то там это решается при помощи хелперов app(). Вот выдержка из документаций:
If you are using it as in your example - you'll get no profit. But Laravel container gives much more power in this resolving that you cannot achieve with simple instantiating objects.
Binding Interface - you can bind specific interface and it's implementation into container and resolve it as interface. This is useful for test-friendly code and flexibility - cause you can easily change implementation in one place without changing interface. (For example use some Countable interface everywhere as a target to resolve from container but receive it's implementation instead.)
Dependency Injection - if you will bind class/interface and ask it as a dependecy in some method/constructor - Laravel will automatically insert it from container for you.
Conditional Binding - you can bind interface but depending on the situation resolve different implementations.
Singleton - you can bind some shared instance of an object.
Resolving Event - each time container resolves smth - it raises an event you can subscribe in other places of your project.
And many other practises... You can read more detailed here
В нашем случае мы вручную создадим динамический контейнер, который нам вернет инстанс со всеми иерархическими объявлениями:
<?php
namespace App\Shared\DI;
use Bitrix\Main\ArgumentException;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionException;
/**
* Класс для генерации контейнеров.
* @package App\Shared\DI
*/
class Container implements ContainerInterface
{
   private array $objects = [];
   /**
    * @inheritDoc
    */
   public function has(string $id): bool
   {
       return isset($this->objects[$id]) || class_exists($id);
   }
   /**
    * Метод возвращает инстанс класса по его ключу.
    *
    * @param string $id
    * @return mixed
    * @throws ArgumentException
    */
   public function get(string $id): mixed
   {
       try {
           return
               isset($this->objects[$id])
                   ? $this->objects[$id]()         // "Старый подход"
                   : $this->prepareObject($id); // "Новый" подход
       } catch (ContainerExceptionInterface|NotFoundExceptionInterface|Exception $exception) {
           throw new ArgumentException($exception->getMessage());
       }
   }
   /**
    * @throws Exception
    */
   private function prepareObject(string $class): object
   {
       try {
           $classReflector = new ReflectionClass($class);
           // Получаем рефлектор конструктора класса, проверяем - есть ли конструктор
           // Если конструктора нет - сразу возвращаем экземпляр класса
           $constructReflector = $classReflector->getConstructor();
           if (empty($constructReflector)) {
               return new $class;
           }
           // Получаем рефлекторы аргументов конструктора
           // Если аргументов нет - сразу возвращаем экземпляр класса
           $constructArguments = $constructReflector->getParameters();
           if (empty($constructArguments)) {
               return new $class;
           }
           // Перебираем все аргументы конструктора, собираем их значения
           $args = [];
           foreach ($constructArguments as $argument) {
               // Получаем тип аргумента
               $argumentType = $argument->getType()->getName();
               // Получаем сам аргумент по его типу из контейнера
               $args[$argument->getName()] = $this->get($argumentType);
           }
           // И возвращаем экземпляр класса со всеми зависимостями
           return new $class(...$args);
       } catch (ReflectionException|ContainerExceptionInterface|NotFoundExceptionInterface $e) {
           throw new Exception($e->getMessage());
       }
   }
}Таким образом, все внедренные зависимости в сервис всегда будут одним и тем же объектом, и мы сможем изменять значения в дочерних классах, при этом в самих классах, используя зависимости, к примеру:
CurlService::create()->client()->setUrl($url);
CurlService::create()->actions()->send($message);
Так как в CurlActions такой же инстанс подключается через иерархическую зависимость:
readonly class CurlActions implements ActionsInterface
{
   /**
    * @throws ArgumentException
    * @throws SystemException
    */
   public function __construct(
       private CurlClient $client
   )
   {
       $this->client->init();
   }2. Инфраструктурные сервисы
Раз мы заговорили о CURL, он является типичным представителем инфраструктуры, но легко заменяется тем же Guzzle, и большинство сервисов инфраструктуры легко взаимозаменяемы, как, например, разные провайдеры СМС.
Тут нам понадобится фабрика, построенная на интерфейсах, а не конкретной реализации. К примеру, у curl и guzzle должен быть метод init(), метод setBody() и метод send(). А вот их конкретная реализация нас мало интересует.
Подготавливаем родительский интерфейс для сервиса:
<?php
namespace App\Infrastructure\Contracts;
interface BaseServiceInterface
{
   /**
    * Method create service by key.
    * @param int $typeId  ID сервиса.
    * @return mixed
    */
   public static function create(int $typeId): mixed;
}Через $typeId будем передавать, какой именно тип сервиса мы хотим получить. Чтобы разработчики не запутались в том, какие сервисы как называются, создаем константы в Enums:
<?php
namespace App\Infrastructure\Enums\Http;
enum ServicesEnums : int
{
   case CURL = 1;
   case GUZZLE = 2;
}И интерфейс для класса с инъекциями, который он будет возвращать:
<?php
namespace App\Infrastructure\Contracts\Http;
interface ServiceInterface
{
   public function client(): ClientInterface;
   public function repository(): RepositoryInterface;
   public function actions(): ActionsInterface;
}Создаем сервис и в него передаем тип:
<?php
namespace App\Infrastructure\Services\Http;
use App\Infrastructure\Contracts\BaseServiceInterface;
use App\Infrastructure\Contracts\Http\HttpRequestInterface;
use App\Infrastructure\Contracts\Http\ServiceInterface;
use App\Infrastructure\Enums\Http\ServicesEnums;
use App\Infrastructure\Enums\Logger\TypeEnums;
use App\Infrastructure\Services\Http\Curl\CurlService;
use App\Infrastructure\Services\Http\Guzzle\GuzzleService;
use App\Shared\DI\Container;
use Exception;
class HttpService implements BaseServiceInterface
{
   /**
    * @throws Exception
    */
   public static function create(int $typeId = ServicesEnums::CURL->value): ServiceInterface
   {
       return match ($typeId) {
           ServicesEnums::CURL->value => (new Container())->get(CurlService::class),
           ServicesEnums::GUZZLE->value => (new Container())->get(GuzzleService::class),
           default => throw new Exception('Unexpected match value'),
       };
   }
   /**
    * Логирование обмена данными.
    *
    * @param HttpRequestInterface $context
    * @param TypeEnums $type Источник логов.
    * @return void
    */
   public static function log(HttpRequestInterface $context, TypeEnums $type = TypeEnums::ORDERS): void
   {
       $method = debug_backtrace()[1]['function'];
       $message = "Http: {$context->method}::{$method} [{$context->id_entity}]";
       LoggerFacade::app('Http' . DIRECTORY_SEPARATOR . $context->service . DIRECTORY_SEPARATOR . $type->name . DIRECTORY_SEPARATOR )->info($message, (array)$context);
       LoggerFacade::elk()->info($message, (array)$context);
   }
}Создаем классы для каждого сервиса по отдельности и возвращаем общие для сервиса интерфейсы:

Каждый сервис предоставляет функциональность через инъекции:
<?php
namespace App\Infrastructure\Services\Http\Curl;
use App\Infrastructure\Contracts\Http\RepositoryInterface;
use App\Infrastructure\Contracts\Http\ServiceInterface;
use App\Infrastructure\Services\Http\Curl\Actions\CurlActions;
use App\Infrastructure\Services\Http\Curl\Client\CurlClient;
readonly class CurlService implements ServiceInterface
{
   public function __construct(
       private CurlClient  $client,
       private CurlActions $actions,
   )
   {
   }
   public function client(): CurlClient
   {
       return $this->client;
   }
   public function repository(): RepositoryInterface
   {
       // TODO: Implement repository() method.
   }
   public function actions(): CurlActions
   {
       return $this->actions;
   }
}В итоге получается, что мы имеем набор контрактов для каждого сервиса, чтобы эти сервисы имели не реализацию, а описание структуры.

Таким образом, мы сможем обращаться уже по тому пути, который приводился в примере выше:
App\Infrastructure\Http\HttpService::create(HTTPEnums::GUZZLE->value)->client()->send( $body, $url)
Итог:
По результату, у нас получилась архитектура
local\App\Domains\<Name>\NameService.php
local\App\Domains\<Name>\<SubDir>\SubDirClass.php
local\App\Infrastructure\Services\<Name>\NameService.php
local\App\Infrastructure\Services\<Name>\<SubDir>\SubDirClass.php
local\App\Shared\Services\<Name>\NameService.php
local\App\Shared\Services\<Name>\<SubDir>\SubDirClass.php
И мы можем выстраивать архитектуру на любую глубину, к примеру:
App\Shared\Services\ManagementService::create()->filesystem()->modules()->psr()->actions()->registerSplByMask(mask: $mask, baseDir : 'local')
Плюс, перенос между проектами папки Infrastructure и Shared происходит простым копированием этих директорий.

 
           
 
amikha1lov
Здравствуйте, подскажите, пожалуйста, а как в такой структуре взаимодействуете с событиями битрикса? Где-то в структуре их храните и в обработчике указываете метод ? Допустим что-то нужно сделать при смене статуса заказа.
DVZakusilo Автор
Вообще, изначально, просто использовали кастомную реализацию событий
и складывали их в соответствии с архитектурой
но когда время позволяло, то делали полноценную файбрику и в ней регистрировали события, тут на статью тянет))
Создаем фабрику, которая наследует битриксовый контейнер и предоставляет нам методы регистраций событий
Прописываем события, чтобы они зарегистрировались
Ну и сами события регистрируются с помощью функционала \Bitrix\Crm\Service\Operation\<Add,Update...>
Первый вариант, конечно проще, второй универсальнее. Если есть интерес, то могу попросить коллегу, написать отдельную статью по такому подходу.
amikha1lov
Спасибо. Было бы очень интересно. Еще хотелось бы про взаимодействие с ORM битрикса в данной архитектуре, интересно как выглядят классы в Models.
И если пишите тесты, то интересно как тестируете допустим создание заказа, smoke тесты или иначе?
DVZakusilo Автор
По поводу взаимодействия с ОРМ.
Есть как хотелось бы: сделать отдельный сервис в инфраструктуре, в котором был бы паттерн адаптер, которому присылаешь SQL или запрос типа GetList. и он сам бы в зависимости от подключенной ОРМ (Bitrix, Eloquent, doctrine) и т д, формировал бы запрос, ну или хотябы прямые sql, которые просто пернаправлялись в соответствующую ормку.
И есть, как есть: сейчас ОРМ жестко завязана на битрикс, тк в init.php ядро подключается, то и битрикс методы доступны, а далее дело техники, лишь бы через ядро D7, что хоть как-то ложится в архитектуру без кучи глобалов и констант. Про этот вариант можно прочитать в прошлой статье
Тесты, хочется и колется, тк заказная разработка а не внутренняя, и время дается на конкретные задачи, клиент в 90% случаев не выделяет время на них, архитектурно - основу положили, но дальше ждем у моря погоды.
Для примера сделали тест для интеграции с airflow, также из другой прошлой статьи, большего пока позволить не можем себе))
DVZakusilo Автор
Для настройки тестов, вот эта статья использовалась
amikha1lov
контроллеры у вас битриксовые используются ?
use Bitrix\Main\Engine\Contract\Controllerable;
use Bitrix\Main\Engine\Controller;
DVZakusilo Автор
Пока да, позиция такая, что нам достался старый проект, который постепенно по кусочку выносим во внешнюю архитектуру, позадачно, с чем работаем - то и выпиливаем.
amikha1lov
может быть подскажите решение, чтобы можно было любой неймспейс использовать для контроллеров, дело в том, что по дефолту в router_index есть вызов - Loader::requireClass($controllerClass); который разбивает неймспейс на части и пытается найти модуль, кидает ошибку There is no "тут неймспейс полный" class, module "первые 2 части неймспейса" is unavailable.
Делать модулями не хочется, пока что решил просто левым нейспейсом из одного слова, который не соответствует тому, что прописан в composer