Организовываем связь между доменными сущностями в Laravel

Организовываем связь между доменными сущностями в Laravel

В разработке проектов я зачастую придерживаюсь архитектурного паттерна Porto. Меня привлекает простота его организации и наличие готового решения на Laravel. Суть его заключается в том, чтобы выносить всю бизнес-логику в Actions, а более мелкие задачи в Tasks, а взаимодействие с базой данных переносить в Repositories. В конечном итоге проект легко масштабируется, просто тестируется, а добавление нового функционала происходит намного быстрее.

Пример реализации данного паттерна вы можете найти в моём github.

Слишком подробно расписывать устройство Porto в данной статье я не буду, всю информацию можно легко найти в документации.

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

В качестве примера возьмём последнюю 9 версию Laravel и перенесём доменный слой в папку src. Вот как выглядит файл composer.json:

    "autoload": {

        "psr-4": {

            "App\\": "app/",

            "Database\\Factories\\": "database/factories/",

            "Database\\Seeders\\": "database/seeders/",

            "Domains\\": "src/Domains",

            "Parents\\": "src/Parents"

        }

    },

Создадим два домена - Product и Category (пока в виде папок в src). Для них зарегистрируем ServiceProviders. Вот содержимое файла CategoryServiceProvider:

<?php

 

namespace Domains\Category\Providers;

 

use Illuminate\Support\ServiceProvider;

 

class CategoryServiceProvider extends ServiceProvider

{

    public function register()

    {

    }

 

    public function boot()

    {

        $this->registerRoutes();

    }

 

    /**

     * @return void

     */

    protected function registerRoutes(): void

    {

        $this->loadRoutesFrom(

            path: __DIR__ . '/../Routes/Api/v1.php',

        );

    }

}

 

Аналогичным образом создаём файл ProductServiceProvider. Не забываем добавить данные классы в файл config/app.php в блок providers.

Миграции для этих двух модулей вы также найдёте в github.

Далее добавляем весь базовый функционал в данные модули. Кратко опишу их структуру на примере модуля Category. Все операции с базой данных у нас происходят в папке Repositories. Вот содержимое файла CategoryRepository:

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Repositories\Eloquent;

 

use Domains\Category\Models\Category;

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Illuminate\Pagination\LengthAwarePaginator;

use Parents\Repositories\QueryBuilder;

use Parents\Repositories\Repository;

 

final class CategoryRepository extends Repository implements CategoryRepositoryInterface

{

    public function all(): LengthAwarePaginator

    {

        return QueryBuilder::for(Category::class)->jsonPaginate();

    }

 

    public function getCategoryBySlug(string $slug): Category

    {

        /** @var Category $category */

        $category = QueryBuilder::for(Category::class)->where('slug', $slug)->firstOrFail();

        return $category;

    }

}

 

Для корректного структурирования json ответов я предпочитаю использовать стандарт json:api. Для этого отлично подходят пакеты от spatie: spatie/laravel-query-builder, spatie/laravel-json-api-paginate, spatie/laravel-fractal.

Для реализации основной бизнес-логики по получению списка категорий создаём Action, который из репозитория будет получать необходимые данные (файл GetAllCategories):

<?php

declare(strict_types=1);

 

namespace Domains\Category\Actions;

 

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Parents\Actions\Action;

 

final class GetAllCategories extends Action

{

    public function __construct(

        protected CategoryRepositoryInterface $repository

    )

    {

    }

 

    public function handle(): \Illuminate\Pagination\LengthAwarePaginator

    {

        return $this->repository->all();

    }

}

 

Всё что нам теперь остаётся, это перебросить Action в контроллер:

<?php

declare(strict_types=1);

 

namespace Domains\Category\Http\Controllers\Api\V1;

 

use App\Http\Controllers\Controller;

use Domains\Category\Actions\GetAllCategories;

use Domains\Category\Transformers\CategoryTransformer;

use League\Fractal\Serializer\JsonApiSerializer;

 

final class CategoryController extends Controller

{

    public function index(): ?array

    {

        return fractal(

            GetAllCategories::run(),

            app(CategoryTransformer::class),

            new JsonApiSerializer()

        )->withResourceName('Category')

            ->toArray();

    }

}

 

Регистрация роута происходит в файле src/Domains/Category/Routes/Api/v1.php:

 

<?php

declare(strict_types=1);

 

use Illuminate\Support\Facades\Route;

 

Route::prefix('api/v1')->as('api:v1:')->group(function () {

 

    Route::prefix('categories')->as('categories:')->group(function () {

        Route::get('/', [\Domains\Category\Http\Controllers\Api\V1\CategoryController::class, 'index'])->name('index');

    });

 

});

 

Итак, мы имеем в данном модуле следующий маршрут:

  1. Запрос попадает в файл с роутингом нашего модуля, который мы через ServiceProviders вынесли в отдельный файл.
  2. Роутинг переводит нас в контроллер, который находится в папке Http/Controllers.
  3. Контроллер переадресует нас в Actions, где хранится вся бизнес-логика.
  4. В случае если бизнес-логика большая, то разбиваем её по задачам. Для этого создаём отдельно файлы в папке Tasks. Но так как у нас на данный момент нет сложной логики, мы обойдёмся и без этого слоя.
  5. Action не взаимодействует напрямую с базой данных. Только через Repository. Заметьте, мы обращаемся с репозиторием через интерфейс, а не напрямую. Это сделано для того, чтобы при необходимости можно было удобнее работать с тестами и переключиться на другую базу данных.
  6. Ответ возвращается в контрллер, который форматирует его через пакет fractal в формат json:api.

Итак, у нас есть два домена - Продукт и Категория. Они на данный момент изолированы друг от друга. За исключением того, что есть системные связи между этими двумя доменами через модели. На них я, как правило, особого внимания не обращаю, так как они располагаются в одном месте и легко администрируются.

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

Если мы будем из actions категорий обращаться напрямую к actions или tasks товаров, то у нашего проекта резко возрастёт связанность между модулями. А это очень плохо по следующим причинам:

  1. Нет возможности написать изолированные тесты. Они начинают затрагивать сторонние модули.
  2. Есть риск появления дополнительных багов. Когда затрагивается один компонент системы, он может повлиять на другой.
  3. Большие трудозатраты в масштабировании и администрировании.
  4. Более высокие расходы в рефакторинге. Неизвестно, как поведёт себя система при замене или корректировки модуля категорий.

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

Одно из решений подобной задачи я увидел в фреймворке Spryker и я быстро перенёс подобную идею в свои проекты.

Итак, давайте рассмотрим первый сценарий. Мы из модуля Product хотим получать категорию по её slug. У нас на данный момент уже есть связь в модели между категорией и товаром, но она сделана по id. А у нас стоит требование - получить её модель при наличии slug.

У нас есть соответствующий метод в CategoryRepository. Давайте для него напишем Action (GetCategoryBySlug.php):

<?php

declare(strict_types=1);

 

namespace Domains\Category\Actions;

 

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Parents\Actions\Action;

 

final class GetCategoryBySlug extends Action

{

    public function __construct(

        protected CategoryRepositoryInterface $repository

    )

    {

    }

 

    public function handle(string $slug): \Domains\Category\Models\Category

    {

        return $this->repository->getCategoryBySlug($slug);

    }

}

 

Как уже было описано выше, вызывать этот Action из компонентов модуля Product - крайне плохая идея. Давайте централизируем всё взаимодействие. Первым делом, чётко покажем, какие методы мы делаем доступными для других модулей. Для этого создадим Facade (файл CategoryFacade):

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Facades;

 

use Domains\Category\Actions\GetCategoryBySlug;

use Domains\Category\Models\Category;

 

final class CategoryFacade extends \Parents\Facades\Facade implements CategoryFacadeInterface

{

    public function getCategoryBySlug(string $slug): Category

    {

        return GetCategoryBySlug::run($slug);

    }

}

 

Всё что он делает - это вызывает наш Action. Как вы заметили, он имплементирует отдельный интерфейс. При необходимости мы можем туда добавить и другие методы, но они нам пока не нужны. Не забудьте зарегистрировать связь интерфейса с классом в RepositoryServiceProvider.

<?php

 

declare(strict_types=1);

 

namespace Parents\Providers;

 

use Domains\Category\Facades\CategoryFacade;

use Domains\Category\Facades\CategoryFacadeInterface;

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Domains\Category\Repositories\Eloquent\CategoryRepository;

use Domains\Product\Facades\ProductFacade;

use Domains\Product\Facades\ProductFacadeInterface;

use Domains\Product\Repositories\Eloquent\ProductRepository;

use Domains\Product\Repositories\ProductRepositoryInterface;

use Illuminate\Support\ServiceProvider;

 

class RepositoryServiceProvider extends ServiceProvider

{

    public function register(): void

    {

        $this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);

        $this->app->bind(CategoryRepositoryInterface::class, CategoryRepository::class);

        $this->app->bind(CategoryFacadeInterface::class, CategoryFacade::class);

        $this->app->bind(ProductFacadeInterface::class, ProductFacade::class);

    }

}

 

И этот файл регистрируем в config/app.php.

Теперь всё что нам остаётся - это подсоединиться к этому фасаду из модуля Product. Как мы это сделаем? Для этого существует паттерн DependencyProvider. Создаём файл src/Domains/Product/Providers/ProductDependencyProvider.php:

<?php

declare(strict_types=1);

 

namespace Domains\Product\Providers;

 

use Domains\Category\Facades\CategoryFacade;

use Domains\Category\Facades\CategoryFacadeInterface;

 

final class ProductDependencyProvider extends \Parents\Providers\DependencyProvider

{

    public function getCategoryFacade(): CategoryFacadeInterface

    {

        return app(CategoryFacadeInterface::class);

    }

}

 

Да, всё что мы в нём сделали - это всего лишь добавили метод, в котором создаём инстанс CategoryFacade. Теперь в любом месте нашей системы мы можем через провайдера получить категорию. Например, через Action уже в папке с модулем Product (файл src/Domains/Product/Actions/GetCategoryBySlug.php):

<?php

declare(strict_types=1);

 

namespace Domains\Product\Actions;

 

use Domains\Category\Models\Category;

use Domains\Product\Providers\ProductDependencyProvider;

use Parents\Actions\Action;

 

final class GetCategoryBySlug extends Action

{

    public function __construct(

        protected ProductDependencyProvider $dependencyProvider

    )

    {

    }

 

    public function handle(string $slug): Category

    {

        return $this->dependencyProvider->getCategoryFacade()->getCategoryBySlug($slug);

    }

}

 

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

Итак, мы сделали первую задачу, переходим ко второй. Получаем из категорий массив товаров по их id.

Первый шаг, создаём фасад (src/Domains/Product/Facades/ProductFacade.php):

<?php

 

declare(strict_types=1);

 

namespace Domains\Product\Facades;

 

use Domains\Product\Actions\GetProductsByIds;

 

final class ProductFacade extends \Parents\Facades\Facade implements ProductFacadeInterface

{

    public function getProductsByIds(array $ids): \Illuminate\Database\Eloquent\Collection|array

    {

        return GetProductsByIds::run($ids);

    }

}

 

Здесь также мы вызываем Action, где из репозитория получает список продуктов.

Создаём файл src/Domains/Category/Providers/CategoryDependencyProvider.php, где и регистрируем связь с модулем Product:

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Providers;

 

use Domains\Product\Facades\ProductFacade;

use Domains\Product\Facades\ProductFacadeInterface;

 

final class CategoryDependencyProvider extends \Parents\Providers\DependencyProvider

{

    public function getProductFacade(): ProductFacadeInterface

    {

        return app(ProductFacadeInterface::class);

    }

}

 

И теперь наш файл src/Domains/Category/Transformers/CategoryTransformer.php может выглядеть например следующим образом:

 

<?php

declare(strict_types=1);

 

namespace Domains\Category\Transformers;

 

use Domains\Category\Models\Category;

use Domains\Category\Providers\CategoryDependencyProvider;

use Domains\Product\Transformers\ProductTransformer;

use League\Fractal\Resource\Collection;

use Parents\Transformers\Transformer;

 

final class CategoryTransformer extends Transformer

{

    public function __construct(

        protected CategoryDependencyProvider $dependencyProvider

    )

    {

    }

 

    protected array $defaultIncludes = [];

 

    protected array $availableIncludes = ['products'];

 

    public function transform(Category $category): array

    {

        return [

            'id' => (int) $category->id,

            'name' => $category->name,

            'slug' => $category->slug,

            'created_at' => $category->created_at,

            'updated_at' => $category->updated_at,

            'products_count' => $this->dependencyProvider->getProductFacade()->getProductsByIds([2,3])->count(),

        ];

    }

 

    public function includeProducts(Category $category): Collection

    {

        return $this->collection($category->products, new ProductTransformer);

    }

}

 

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

 

    public function includeProducts(Category $category): Collection

    {

        return $this->collection($this->dependencyProvider->getProductFacade()->getProductsByCategoryId($category->id), new ProductTransformer);

    }

 

Главное помнить, в фасадах не должно содержаться никакой бизнес-логики. Всё, что должен делать фасад - вызывать Action или в худшем случае Repository. Но не более.

Итак, в этой статье мы рассмотрели два аспекта, как реализовать подход DDD в Laravel и как контролировать связанность между доменными сущностями.

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235