Clean Architecture, DDD, гексагональная архитектура. Разбираем на практике blog на Symfony

Всем привет! Давайте знакомиться ;) Я Аня, и я php разработчик. Основной стек — Magento. С недавних пор начала посматривать налево на Symfony и писать свои Pet Projects на этом фреймворке.

Мне всегда нравилось писать решения которые легко бы расширялись / адаптировались под требования бизнеса (заказчика). И мне всегда хотелось сделать это более 'правильно' и красиво. Так я и познакомилась с понятиями чистой архитектурой.

Мой пост ни в коем случае не претендует на самый правильный. Но позвольте мне здесь донести свои идеи. В комментариях буду рада услышать конструктивную критику к данному посту.

Для нетерпеливых, вот прямая ссылка на гитхаб

Содержание статьи (Теоретическая часть)

  1. Что такое DDD

  2. Что такое гексагональная архитектура

  3. Что такое чистая архитектура

  4. Плюсы и минусы «архитектурной» разработки

Содержание статьи (Практическая часть)

  1. Введение

  2. Разбиваем проект на фичи

  3. Работа с данными: DataManagerFeatureApi

  4. Манипуляция с данными: DoctrineDataFeature (implements DataManagerFeatureApi)

  5. CategoryFeatureApi и CategoryFeature

  6. PostFeatureApi и PostFeature

  7. FrontFeature

  8. Итоговый вариант

Прежде чем мы начнём разбирать как написать блог на Symfony с DDD и Clean Architecture, разберем основную теорию. И так, приступим…

Что такое DDD (Domain Driven Design | Domain Driven Development)

DDD (Domain Driven Design | Domain Driven Development) — это архитектурный подход, задача которого — это выделение бизнес логики (Domain) приложения. Отсюда и название — Domain Driven. Здесь также принимаются во внимание проектирование классов, паттерны, хорошие практики и тд, но это все внутри доменного слоя.

Доменный слой (Domain) не зависим от внешних библиотек, Domain не привязан к базе данных, к поисковому движку и никогда ничего не знает про детали реализации вашего приложения (например, какую БД использовать, вид кеша и тд). Также доменный слой не привязан к фреймворку, если вы используете, например, DDD на Symfony, то в идеале, перенесенный доменный слой на Laravel (либо другой фреймворк), должен сохранять свою работоспособность.

Иными словами, Domain — это сердце приложение, максимально защищенное от изменения извне. Здесь происходят самые важные процессы, касаемые бизнес-правил, то, без чего бизнес не может существовать.

Совсем кратко: Domain Driven Design | Domain Driven Development — это выделение бизнес логики и его изоляция от внешних факторов.

Что такое гексагональная архитектура (Hexagonal architecture)

Гексагональная архитектура — это вид архитектуры, который определяет способ общения элементов друг с другом путем разделения приложения на слои (layers), которые в свою очередь, используют так называемые порты для общения друг с другом.

Что такое порт в гексагональной архитектуре? Если говорить языком ООП — это интерфейс. Каждый слой использует порт-интерфейс другого слоя, а не конкретную реализацию.

Что же такое слои приложение (application«s layers)?

И так, слой — это группировка проекта, где каждая группа имеет свое конкретное предназначение. В зависимости от нужд проекта, могут появляться дополнительные слои, либо сокращаться количество слоев.

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

Presentation Layer (слой представления) — здесь находится все, что отвечает за input/output (ввод/вывод), например, шаблоны, консольные команды, контроллеры. Весь input/output конвертируется в DTO Request (data transfer object) и передается на слой ниже, в Application Layer. Так же, данный слой занимается отображением DTO Response из Application Layer. Здесь только ввод и вывод, никакой логики данный слой в себе не содержит.

Application Layer (слой приложения) — прослойка между Domain и Presentation, здесь происходит получениe DTO Request из input/output (Presentaion Layer), первичная обработка / валидация ввода, маппинг DTO объектов в Domain Layer и наоборот, вызов бизнес логики из Domain Layer.

Иными словами, получили реквест, произвели маппинг в Domain Entity, вызвали интерактор / usecase, получили результат от Domain Layer, сделали мапинг в DTO Response и вернули на слой Presentation, где уже будет показан результат пользователю (если необходимо)

Domain Layer (доменный слой) — слой с бизнес логикой. Чуть более подробно разберем в разделе Clean Architecture. ВАЖНО: В идеале, доменный слой не знает про существование других слоев.

Infrastructure — слой, работающий со сторонними библиотеками, фреймворками и тд.

Наглядно схему слоев в гексагональной архитектуре можно изобразить так:

2a70e5cd6f32c6734dd928af18fe696c.png

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

Что такое чистая архитектура (Clean Architecture)

Clean Architecture — была описана Робертом Мартином («Дядюшкой Бобом») — очень рекомендую его книги к прочтению. Данная архитектура основывается на гексагональной архитектуре, однако с небольшими дополнениями. Здесь вводятся такие понятия, как: Use Case, Interactor, Entity.

Что же такое Domain Entity простыми словами? Это сущности, без которых бизнес просто не может существовать. Например, давайте представим банк, и отбросим все детали реализации. Что будет являться domain entity для банка? Это наверняка будет UserEntity, AccountEntity и тд.

Что же такое Use Case простыми словами? По сути, это процессы, без которых не может существовать бизнес. На примере банка, Use Case будет являться открытие счета, создание вклада, закрытие счета, заказ карты и тд.

Что же тогда Intercator означает? Это механизм, объединяющий несколько Use Case. Говоря на языке ООП — это класс, в котором описаны несколько Use Case. Как правило, это делается для того, чтобы сделать код более компактным.

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

Плюсы и минусы чистой архитектуры :

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

Начнем с минусов:

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

- Дороговизна в разработке - данный пункт можно разделить на 2 момента. Во-первых, такой подход требует как минимум грамотного архитектора, либо сеньера, который изначально имеет большой рейт за час. Во-вторых, на построение архитектуры и выполнение задач требуется больше времени, тк выделение доменного слоя и написание экосистемы для коммуникации с доменом требует бОльшего написания кода (на первых этапах).

— Много однотипного кода (не путать с копипастой!) — приготовьтесь писать множество кода, да и еще схожего друг с другом от фичи к фиче (и от слоя к слою). Да, api, вечные маппинги и тд иногда очень сильно нервируют.

— Постоянный самоконтроль — если на уровне языка не предусматривается разделение кода на пакеты (как в Java), нужно постоянно себя контролировать, чтобы не использовать классы/интерфейсы из других слоев/фич, минуя api.

Теперь поговорим о плюсах чистой архитектуры:

— Слабая зависимость кода — тк приложение разделено на слои, где каждый слой изолирован друг от друга, то изменение в одном слое не влияет на другой.

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

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

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


Когда не нужно использовать чистую архитектуру?

Чистая архитектура — это очень мощный инструмент для создания действительно расширяемого и стабильного приложения. Это не только теория / инструкция, но так же и образ мышления. Когда же использования чистой архитектуры не будет оправдывать себя? Я бы сказала, что она всегда себя оправдывает. Ведь хорошо написанное приложение еще никогда не вызывало негативной реакции! Однако не стоит забывать про коммерцию и бизнес. Не все клиенты, у которых проекты с маленьким жизненным циклом, готовы платить больше за хорошую архитектуру. Но это не означает, что маленькие проекты не заслуживают хорошего кода! =)

И так, теперь к практике:

Практика: Создание блога на Symfony с использованием чистой архитектуры (Clean Architecture)

Выше я писала про разделение приложения на слои. Однако довольно часто приложение разбивается не только на слои, но и на фичи. Каждая фича имеет такой же набор слоев, что были описаны выше. (допускается добавление/удаление определенных слоев в фичах). Мне больше нравится вариант разделения проекта по фичам.

И так, какой план? Давайте попробуем создать простой блог, в котором будет возможность написания постов и вывод их в категории.

Прежде, чем начнем, скажу сразу — я не симфони девелопер, мне нравится этот фреймворк и обкатывать свои проекты/идеи. Я знаю, что у симфони есть свой best practice, в котором описаны «папочки» проекта и что там должно быть. Но папочки — это не архитектура, не так ли? А фреймворк предлагает довольно легкую кастомизацию «папочек» под свои нужды. И вообще, фреймворк — это всего лишь деталь реализации.

Если кому-то проще сразу смотреть исходники, то оставлю еще раз тут ссылку на репозиторий — прямая ссылка на гитхаб

И так, приступим.

Разбиваем на фичи

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

Каждая фича будет иметь свой набор API и его реализовывать (не путать с REST). Это делается для того, что бы иметь возможность использовать так называемые «порты» для общения фич друг с другом и не быть привязанным к конкретной реализации. Теперь финальный список фич:

  • CategoryFeature — здесь будет работа с категориями

  • CategoryFeatureAPI — набор апи, открытый для сторонних модулей

  • PostFeature — здесь работа с постами

  • PostFeatureAPI — набор апи, открытый для сторонних модулей

  • DataManagerFeatureAPI — набор апи, позволяющий работать с DataStorage (это может быть база данных, текстовый файл и тд)

  • DoctrineDataFeature — реализовывает DataManagerFeatureAPI и работает с доктриной

  • FrontFeature — работа с фронтом приложения (шаблоны, контроллеры и тд). Не требует апи.

  • AdminFeature (не буду ее реализовывать в данном примере, будет домашним заданием)

Наглядная схема проекта:

920875e0f69903884a30cc32fc8cfc01.png

Как это выглядит в самом проекте:

bf38a38d585da78e7f8a9353af4a58fe.png

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

Работа с данными (DataManagerFeatureApi)

Я не хочу привязываться к доктрине, с которой работает симфони. Точнее, я хочу иметь возможность не только работы с ней, но и с другими механизамами работы с данными. Сегодня мы работаем с доктриной, а завтра переходим на csv файлы (да, кейс космический, но такое тоже может быть). Не переписывать же весь проект заново?

По этому нам нужен какой то набор интерфейсов, который будет описывать работу с нашими данными. Такой набор как правило выносится в API фичу. По этому мы создадим DataManagerFeatureApi, который будет описывать порты работы с данными.

И так, что будет внутри этой фичи:

  • DTORequest — описание входящих данных в модуль.

  • DTORequestFactory — описание фабрик

  • DTOResponse — описание респонс объектов для сторонних модулей / фич

  • Service — список «открытых» методов для сторонних модулей для манипуляции данными (прим, сохранение сущности).

Наглядный пример из проекта:

7f7dfd19dc207c733cca37735ea8e9c9.png

Описание интерфейсов прилагается:

CategoryDataRequestInterface — реквест объект для манипуляции с данными в DataStorage

PostDataRequestInterface — реквест объект для манипуляции с данными в DataStorage

CategoryDataRequestFactoryInterface — просто фабрика для удобства создания объекта реквеста

PostDataRequestFactoryInterface — просто фабрика для удобства создания объекта реквеста

CategoryDataResponseInterface — респонс объект категорий, доступный другим фичам

PostDataResponseInterface — респонс объект поста, доступный другим фичам

CategoryDataServiceInterface — описание методов для работы с данными категорий

PostDataServiceInterface — описание методов для работы с данными для постов

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

DoctrineDataFeature (implements DataManagerFeatureApi)

Вы можете создать EloquentFeature, как пример, если не хотите работать с доктриной. Вы можете иметь несколько DataManagerFeatures и переключаться между ними используя DI (dependecy injection). В этом и заключается прелесть чистой архитектуры с разбивкой по фичам.

Данная фича у нас реализует DataManagerFeatureApi. Прошу заметить, что реализация API модулей находится в Application слое каждой фичи. Именно данный слой является следующий после input/output.

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

Список слоев:

  • Application — обработка «requested data» и возврат респонса. Классы с данного слоя могут быть использованны сторонними модулями используя API feature (в нашем случае DataManagerFeatureApi)

  • Domain — защищенные сущности от внешнего мира

  • Infrastructure — имплементация работы с доктриной

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

Давайте рассмотрим код в деталях.

Вседа начинаю работу с выделения доменного слоя, а затем перехожу к другим слоям.

Domain layer

Domain layer (Entity) — описание сущностей доктрины. Важно помнить, что все, что находится в домене ни в коем случае не отдается наружу! Тк эта фича у нас создана для работы с доктриной, то и сущности создаются с привязкой к ней.

Category

id = $id;
    }

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return $this
     */
    public function setTitle(string $title): self
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     * @return $this
     */
    public function setContent(?string $content): self
    {
        $this->content = $content;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string $slug
     * @return $this
     */
    public function setSlug(string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    /**
     * @return bool
     */
    public function getIsActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     * @return $this
     */
    public function setActive(bool $active): self
    {
        $this->isActive = $active;
        return $this;
    }
}

Post

id = $id;
    }

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return $this
     */
    public function setTitle(string $title): self
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     * @return $this
     */
    public function setContent(?string $content): self
    {
        $this->content = $content;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string $slug
     * @return $this
     */
    public function setSlug(string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    /**
     * @return bool
     */
    public function isPublished(): bool
    {
        return $this->isPublished;
    }

    /**
     * @param bool $published
     * @return $this
     */
    public function setPublished(bool $published): self
    {
        $this->isPublished = $published;
        return $this;
    }

    /**
     * @return Category|null
     */
    public function getCategory(): ?Category
    {
        return $this->category;
    }

    /**
     * @param Category|null $category
     * @return Post
     */
    public function setCategory(?Category $category): self
    {
        $this->category = $category;
        return $this;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function getCreatedAt(): ?DateTimeInterface
    {
        return $this->createdAt;
    }

    /**
     * @param DateTimeInterface|null $timestamp
     * @return $this
     */
    public function setCreatedAt(?DateTimeInterface $timestamp): self
    {
        $this->createdAt = $timestamp;
        return $this;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function getUpdatedAt(): ?DateTimeInterface
    {
        return $this->updatedAt;
    }

    /**
     * @param DateTimeInterface|null $timestamp
     * @return $this
     */
    public function setUpdatedAt(?DateTimeInterface $timestamp): self
    {
        $this->updatedAt = $timestamp;
        return $this;
    }

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    /**
     * @return void
     */
    protected function updateTimestamps(): void
    {
        $dateTimeNow = new DateTime('now');

        $this->setUpdatedAt($dateTimeNow);

        if ($this->getCreatedAt() === null) {
            $this->setCreatedAt($dateTimeNow);
        }
    }
}

Domain layer (Repository) — важно! в домене никогда не реализуются репозитории. За реализацию отвечате инфраструктурный слой.

CategoryRepositoryInterface

PostRepositoryInterface

Давайте спустимся еще на уровень ниже и опишем инфрастуктурный слой.

Infrastructure layer

Infrastructure layer (Repository) — реализация репозиториев

CategoryRepository

getId()) {
            $this->_em->merge($category);
        } else {
            $this->_em->persist($category);
        }

        $this->_em->flush();

        return $category;
    }

    /**
     * @return object[]
     */
    public function getList(array $criteria = null): array
    {
        if (!$criteria) {
            return $this->findAll();
        }

        return $this->findBy($criteria);
    }

    /**
     * @param int $id
     * @return Category|null
     */
    public function getById(int $id): ?Category
    {
        return $this->find($id);
    }

    /**
     * @param string $slug
     * @return object|null
     * @throws NonUniqueResultException
     */
    public function getBySlug(string $slug): ?object
    {
        $qb = $this->createQueryBuilder('q')
            ->where('q.slug = :slug')
            ->setParameter('slug', $slug);

        $query = $qb->getQuery();

        return $query->setMaxResults(1)->getOneOrNullResult();
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $category = $this->find($id);

        if (!$category) {
            throw new NotFoundHttpException(sprintf("The category with ID '%s' doesn't exist", $id));
        }

        $this->delete($category);
    }

    /**
     * @param object $category
     * @return void
     */
    public function delete(object $category): void
    {
        if (!$category instanceof Category) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this repository.', Category::class)
            );
        }

        $this->_em->remove($category);
        $this->_em->flush();
    }
}

PostRepository

getId()) {
            $this->_em->merge($post);
        } else {
            $this->_em->persist($post);
        }

        $this->_em->flush();

        return $post;
    }

    /**
     * @return object[]
     */
    public function getList(array $criteria = null): array
    {
        if (!$criteria) {
            return $this->findAll();
        }

        return $this->findBy($criteria);
    }

    /**
     * @param int $id
     * @return Post|null
     */
    public function getById(int $id): ?Post
    {
        return $this->find($id);
    }

    /**
     * @param string $slug
     * @return object|null
     * @throws NonUniqueResultException
     */
    public function getBySlug(string $slug): ?object
    {
        $qb = $this->createQueryBuilder('q')
            ->where('q.slug = :slug')
            ->setParameter('slug', $slug);

        $query = $qb->getQuery();

        return $query->setMaxResults(1)->getOneOrNullResult();
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $post = $this->find($id);

        if (!$post) {
            throw new NotFoundHttpException(sprintf("The post with ID '%s' doesn't exist", $id));
        }

        $this->delete($post);
    }

    /**
     * @param object $post
     * @return void
     */
    public function delete(object $post): void
    {
        if (!$post instanceof Post) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this repository.', Post::class)
            );
        }

        $this->_em->remove($post);
        $this->_em->flush();
    }
}

После описания непосредственно домена и работы (в нашем случае) с базой данных, приступим к реализации взаимодействия модуля с реквест объектами.

Application layer

Application layer (ApiService) — описание сервисов, которые доступны для сторонних модулей (фич). Здесь на вход принимается ResponseDTO и возвращается RequestDTO

CategoryService

dataMapper = $dataMapper;
        $this->categoryRepository = $categoryRepository;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        $list = [];
        $result = $this->categoryRepository->getList($criteria);

        foreach ($result as $item) {
            $list[] = $this->dataMapper->toResponse($item);
        }

        return $list;
    }

    /**
     * @param int $categoryId
     * @return CategoryDataResponseInterface|null
     */
    public function getById(int $categoryId): ?CategoryDataResponseInterface
    {
        $entity = $this->categoryRepository->getById($categoryId);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }

    /**
     * @param CategoryDataRequestInterface $dtoRequest
     * @return CategoryDataResponseInterface
     */
    public function save(CategoryDataRequestInterface $dtoRequest): CategoryDataResponseInterface
    {
        $entity = $this->dataMapper->toEntity($dtoRequest);
        $this->categoryRepository->save($entity);

        return $this->dataMapper->toResponse($entity);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->categoryRepository->deleteById($id);
    }

    /**
     * @param string $slug
     * @return CategoryDataResponseInterface|null
     */
    public function getBySlug(string $slug): ?CategoryDataResponseInterface
    {
        $entity = $this->categoryRepository->getBySlug($slug);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }
}

PostService