Ответы пользователя по тегу Предметно-ориентированное проектирование
  • Протечка или какие зависимости могут быть между слоями в DDD?

    @vova07
    Поздний ответ, но я бы все-же добавил одну поправку к комментам egor_nullptr и yurygolikov : утверждая что домен может напрямую иметь связь с инфраструктурой является некорректным ответом во всех случаях.

    Я бы привел для примера более наглядную картинку а именно вот эту:
    layers.jpg

    Она почти идентична той что была приведена выше, только её воспринимать можно легче, а именно то что инфраструктура может быть связанна со всеми слоями но только в виде зависимости.
    Что это означает?!
    Означает это то что в каждом слое есть свои контракты (интерфейсы) которые описывают собственные требования к своим зависимостям, а именно к инфраструктуре в данном конкретном примере, которые обязательны для корректной работы конкретного слоя.

    Я понял что имел введу Юрий, но это избавляет от всех плюшек которые предоставляет ДДД идеология, а именно гибкость и легкая возможность замены любого слоя безболезненно для других. (А мы живем в такое время когда инструменты очень быстро "выходят из моды").

    Вам дали уже верный ответ, но я бы хотел дополнить второй пункт только:
    Это я говорю по опыту а не по книжкам.

    Интерфейс нужен если вы пишите длительный проект, (но верно, он НЕ обязательный) вам только надо понять почему это так.

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

    ДДД это идеология которая позволяет писать проекты поддержка которых является более легкой. И именно это надо учитывать при написании любой части кода. Если класс используется в двух сервисах только и больше нигде, то думать о контрактах надо тогда когда появляется необходимость, но если заводится гидратор, то это уже надолго и везде, и надо принимать меры сразу.
    Ответ написан
    2 комментария
  • Как подружить REST API и концепцию DDD?

    @vova07
    Приветствую!

    Попробую как можно короче объяснить суть и упущенные моменты которые наблюдаются в вашем примере.

    Ответ 1:
    Прочитайте про так называемые доменные сервисы (Domain Services) и приложенческие сервисы (Application Services). В других статьях ответ на ваш вопрос люди называют Use Cases.
    Юз кйэс (Use Case) - это отдельный сервис который не нарушает stateless и у которого одна конкретная задача.

    Я напишу все примеры на PHP (это самый простой язык который вероятнее всего знаете и вы - извиняюсь за это но думаю лучше Go, Swift, Haskell для этого случая):

    final class CreateCustomer
    {
    	/**
    	 * @var IWriteRepository Интерфейс репозитория, где на самом деле хранистя конкретная имплементация записи сущности.
    	 */
    	private $repository;
    
    	public function __construct(IWriteRepository $repositorym Validator $validator)
    	{
    		$this->repository = $repository;
    		...
    	}
    
    	/**
    	 * @param string $id ИД будущей записи.
    	 * @param array $data ПОТ данные которые мы получаем в момент АПи запроса.
    	 */
    	public function handle(string $id, array $data) : void
    	{
    		/**
    		 * Валидация выбрасывает исключение если данные не валидны или возвращает массив валидных данных.
    		 */
    		$dto = $this->validator->validate($data);
    
    		$customer = new CustomerEntity(new UUID($id));
    		/* Ентити это сущность которое содержить исключительно только бизнес логику и безнесс поведения. 
    		 * В вашем примере у вас были разные сеттеры, в правильном подходе это лишенно смысла,
    		 * и совсем неправильно. Думайте об методах аггрегата (Сушность с которой работает репозиторий это ни * что инное как аггрегат) как проекция бизесс логики. 
    		 * для примера в реальном мире принято говорить `Новый клиент зарегистрировался в системе`, мы никогда * не перечисляем цепь проделанных событий. Кто-то говорит `Клиент заполнил свой аддрес, ФИО, потом
    		 * телефон, и отправил данные` ?!
    		 * 
    		 * Если вы заметили правильно, то в конструкторе сущности передается только идентификатор.
    		 * Такой подход похож на реальную ситуацию, что позволяет легко проектировать бизнес логику.
    		 * регистрация клиента можно сравнить с регистрацией карточки пользователя в магазине.
    		 * - Берём бумагу (или специальный блокнот)
    		 * - Указываем номер клиента (обычно его как-то генерят например дата, время, или номер карточки 
    		 * которую ему выдают)
    		 * - начинаем спрашивать клиента кго персональные данные и заполняем все в ОДНОМ процессе.
    		 * - Потом кидаем блокнот/бумагу оратно или в колецию где лежит другая похожая инфа.
    		 */
    		$customer->register($dto['name'], ...);
    
    		/**
    		 * Именно этот метод делает сохранение нового клиента.
    		 * Сама реализация интереса живет в персистентном слое (`persistence layer`).
    		 */
    		$this->repository->store($customer);
    	}
    }


    Пример выше демонстрирует обычный юз кйэс который является (Application Service) и который живет в слое приложения. Хочу заметить что он делает ровно одну операцию (создает нового клиента) и ничего не возвращает.

    Примечание: Если обратите внимание то юз кйэс в своем методе "handle" принимает только скалярные данные, это важный момент который позволяет писать реюзабельные юз кйэсы. Самый простой пример это веб АПИ и консольная команда которая вводится в терминале.
    Валидация данных делается в том же классе (это не отменяет доменную валидацию но показывает один из хороших мест это можно делать в слое приложения.)
    Другое примечание сводится к тому что очень важно соблюдать простое правило: (SRP) - один юз кэйс одна операция.

    Ответ на вопрос который вероятнее всего будет: Как быть если нам надо создать клиента и вернуть ответ, то есть в обычной REST ситуации ?

    final class PostController extend Controller
    {
    	public function __construct(IWriteRepository, CreateCustomer $createCustomUseCase, RetrieveCustomer $retrieveCustomerUseCase)
    	{
    		...
    	}
    
    	public function __invoke(array $data)
    	{
    		$id = $this->repository->pickNextId();
    		
    		$this->createCustomUseCase->handle($id, $data);
    
    		return $this->retrieveCustomerUseCase->handle($id);
    	}
    }


    Примечание: В реальном мире есть два типа репозиторием, один который записывает данные и делает все манипуляции с данными - WriteRepository и второй Тольятти для чтения который используется во всех других юз кйэсах где нужно только выбирать данные - ReadRepository

    Ответ 2:
    REST API - не подразумевает то что вы описываете.
    В REST идеологии на запрос клиента надо вернуть тот же ответ. То есть сокращенно это подразумевает получение целого ресурса (ресурс - так называемая схема запроса/ответа) и возврат его же. (Ответ может содержать дополнительные данные но они вспомогательные, такие как хедеры, пагинация, и так далее.)

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

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

    Если там входит логи, временные тэги, дополнительные данные для других последующих операций, то сущность спроектирована неправильно.

    В одной сущности (Entity) могут быть только атрибуты для удовлетворения поведений этой сущности, все остальное это не часть сущности.
    Сеттеры это анти-паттерн который надо всегда избегать.

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

    Итог:
    разные сущности могут содержать в себе (инкапсулировать) разные Value Objects разделение классов по строгим типам очень важно. Если есть цикл жизни и идентификатор то это сущность, если нету цикла то это ВО (VO = Value Object).

    Ваш CustomerState не имеет цикла жизни что сразу ограничивает вас в использовании сущности. Это сирого ВО. А если в логике появляются разные "if", "else" (лично мое мнение которое разделяют другие разработчики) то это признак плохой архитектуры, и повод задуматься над разделением Во на два или на подтипы. "VipState", "RegularState" но это больше правда для ситуаций с количеством от Ю 2 значений, если их два, то имеет место сделать белковый флаг который будет указывать на то или другое значение.

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

    П.С.С: Все выше сказанное это личные наблюдения, и персональная интерпретация теории. Вы МОЖЕТЕ учитывать мои ИДЕИ но НЕ НАДО СЛЕДОВАТЬ моим интерпретациям.
    ДДД это принцип мышления, еще никто в мире не смог доказать и описать 100% правильный подход, который бы должен быть быть эталоном, даже автор мышления.
    Пробуйте, экспериментируйте и делитесь опытом!

    Удачи!
    Ответ написан
    Комментировать
  • Как организовать доступ по канонам DDD?

    @vova07
    Приветствую!
    Мне кажется вы чуток неправильно следуете принципам ДДД.
    Точнее вы неправильно проектируете свой код.
    Если вы пишите по ДДД то вам не надо задумываться об полях в БД или о других инфраструктурных вещах.
    Вам надо писать домен таким каким он есть в рамках вашего понимания.

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

    <?php
    
    /**
     * "Domain/Directories/File.php"
     */
    final class File
    {
      /**
       * Я использую название файла как его уникальный идентификатор.
       * Надо учитывать что этот идентификатор уникален в рамках одной директории (папки).
       * В случае если это сложно отслеживать можно использовать UUID как ИД и атрибут $name как допольнительнй.
       *
       * @var string
       */
      private $name;
      /**
       * @var bool
       */
      private $hidden = false;
    
      /**
       * @param string $name
       */
      public function __construct(string $name)
      {
        $this->name = $name;
      }
    
      /**
       * Скрываем временно файл.
       */
      public function hide() : void
      {
        $this->hidden = true;
      }
    
      /**
       * Востанавливаем доступ к срытому файлу.
       */
      public function show() : void
      {
        $this->hidden = false;
      }
    
      /**
       * @return string
       */
      public function getName() : string
      {
        return $this->name;
      }
    
      /**
       * @return bool
       */
      public function isHidden() : bool
      {
        return $this->hidden;
      }
    }
    
    /**
     * Aggregate Root.
     * По моегму видению файл не может существовать без родительской папке.
     * По этому папка и есть наш аггрегат.
     *
     * "/Domain/Directories/Directory.php"
     */
    final class Directory
    {
      /**
       * Название директории (папки) играет роль уникального идентификатора.
       * Так-же как и в случае с файлом этот идентифактор может быть уникален только в рамках другой директории (папки).
       * В случае если это сложно отслеживать можно использовать UUID как ИД и атрибут $name как допольнительнй.
       *
       * @var string
       */
      private $name;
      /**
       * @var File[]
       */
      private $files = [];
      /**
       * @var array
       */
      private $removedFiles = [];
      /**
       * @var string
       */
      private $ownerId;
      /**
       * @var bool
       */
      private $hidden = false;
    
      /**
       * @param string $name
       */
      public function __construct(string $name, string $ownerId)
      {
        $this->name = $name;
        $thi->owner = $ownerId;
      }
    
      /**
       * Этот метод не должен использоватся нигде больше кроме в MySQLStorage или в других сторэджах.
       * Это дополнительный метод который нужен потому что другие доменные методы которые описывают бизнес логику могут содержать например события.
       * Чтобы не выбрасывать все события при восстановлении используется такого рода хак.
       * Вы же можете сипользовать что-то другое если у вас есть более приемлемый вариант. 
       * Я пока лучше что-то не нашел.
       * 
       * @internal
       */
      public function reconstruct(bool $hidden, File ...$files) : void
      {
        $this->hidden = $hidden;
    
        foreach ($files as $file) {
          $this->files[$file->getName()] = $file;
        }
      }
    
      /**
       * @param File $file
       */
      public function addFile(File $file) : void
      {
        $this->files[$file->getName()] = $file;
      }
    
      /**
       * @param File[] $files
       */
      public function addFiles(Files ...$files) : void
      {
        foreach ($files as $file) {
          $this->addFile($file);
        }
      }
    
     /**
      * Это то что вы называете "запретить навсегда".
      * Надо трактировать удаление файле как `sof delete`.
      * То есть файл не удаляется реально а просто ставится какой небудь флаг типа `deletedAt` которое показывает время удаления.
      * Хочу заметить что это один из вариантов реализации, так как их много.
      * Может возникнуть вопрос почему мы не делаем это удаление через сам файл `File.php`
      * это потому что возможно этот файл удален в одной директиве а в другой нет.
      * То есть возможно реальный файл есть на диске а его сслки в БД дублируются или еще что, и каждая директория сама контролирует это для себя.
      * Вы можете удалить этот метод, и реальизовать свою логику как вам угодно.
      *
      * @param string $name
      */
      public function removeFile(string $name) : void
      {
        if (!isset($this->files[$name])) {
          throw new OutOfBoundsException('Invalid file ID');
        }
    
        $this->removedFiles[] = $name;
      }
    
      /**
       * Скрываем временно дирекорию.
       */
      public function hide() : void
      {
        $this->hidden = true;
      }
    
      /**
       * Востанавливаем доступ к срытой директории.
       */
      public function show() : void
      {
        $this->hidden = false;
      }
    
      /**
       * @return string
       */
      public function getName() : string
      {
        return $this->name;
      }
    
      /**
       * @return string
       */
      public function getOwnerId() : string
      {
        return $this->ownerId;
      }
    
      /**
       * @return File[]
       */
       public function getFiles() : array
       {
         return $this->files;
       }
    
       /**
        * @return array
        */
        public function getRemovedFiles() : array
        {
          return $this->removedFiles;
        }
    
       /**
        * @return bool
        */
       public function isHidden() : bool
       {
         return $this->hidden;
       }
    }
    
    /**
     * "Domain/Directories/Repository.php"
     */
    final class Repository
    {
      /**
       * @var Storage
       */
      private $storage;
    
      /**
       * @param Storage $storage
       */
      public function __construct(Storage $storage)
      {
        $this->storage = $storage;
      }
    
      /**
       * @param Directory $directory
       */
      public function store(Directory $directory) : void
      {
        $this->storage->store($directory);
      }
    
      /**
       * @return array
       */
      public function getDirectoriesByOwner(string $ownerId) : array
      {
        $this->storage->getDirectoriesByOwner($ownerId);
      }
    }
    
    /**
     * "Domain/Directories/Storage.php"
     */
    interface Storage
    {
      /**
       * @param Directory $directory
       */
      public function store(Directory $directory) : void;
    
      /**
       * @return array
       */
      public function getDirectoriesByOwner(string $ownerId) : array;
    }
    
    /**
     * "Infrastructure/Persistence/Directories/MySQLStorage.php"
     */
    final class MySQLStorage implments Storage
    {
      /**
       * Название поля в БД.
       */
      private CONST TABLE = 'Direcotry';
    
      /**
       * @var PDO
       */
      private $database;
    
      /**
       * @param PDO $database
       */
      public function __construct(PDO $database)
      {
        $this->database = $database;
      }
      /**
       * @param Directory $directory
       */
      public function store(Directory $directory) : void
      {
        // Провекра на сущестование директории в БД.
        if (/* Ваша проверка на наличие в БД */) {
          // Подгоавливаем директорию для сохранения.
          $this->prepareRowData($direcotry);
          // TODO: Обновите директорию в БД.
        } else {
          // Подгоавливаем директорию для сохранения.
          $this->prepareRowData($direcotry);
          /**
           * TODO: Создайте новую директорию в БД.
           * Этот процесс вероятнее всего в релационной БД будет содержать несколько шагов.
           * Добалвение самой директории (папки), добалвение всех файлов в этой директории возможно в отдельной таблице.
           * Ну и все другие операции которые уместны в этом сценарии.
           */
        }
      }
    
      /**
       * @return array
       */
      public function getDirectoriesByOwner(string $ownerId) : array
      {
        /**
         * Именно в этом методе и происходит выборка и фильтрация нужных директорий (папок) и файлов.
         * Вы легко можете выбрать все файлы кторые не удаленны и не скрытие. Или все, или те которые вы посчитаете нужным.
         * `WHERE Directory.isHidden = false AND File.isDeleted = false AND File.isHidden = false`
         * То есть хочу заметить что вы свободны использовать любую структуру БД, которая вам подходит.
         * Для целесности этой цепочки ниже после выборки данных вызывается метод преобразования из массива в Entity.
         */
    
        // Запрашиваем данные из БД6 после чего преобразовываем все в доменные сущности и возвращаем результат.
    
        $result = [];
    
        foreach ($rows as $row) {
          $result = $this->buildEntity($row);
        }
    
        return $result;
      }
    
      /**
       * Этот метод конвертирует Entity в массив для дальнейшего его сохранения.
       *
       * @return array
       */
      private function prepareRowData(Directory $directory) : array
      {
        $files = [];
    
        foreach ($direcotry->getFiles() as $file) {
          $files[] = [
            'name' => $file->getName(),
            'isHidden' => $file->isHidden(),
            'isDeleted' => in_array($file->getName(), $direcotry->getremovedFiles()),
          ];
        }
    
        return [
          'name' => $direcotry->getName(),
          'ownerId' => $direcotry->getOwnerId(),
          'isHidden' => $direcotry->isHidden(),
          'files' => $files,
        ];
      }
    
      /**
       * @param  array $row
       *
       * @return Directory
       */
      private function buildEntity(array $row) : Directory
      {
        $files = [];
    
        foreach ($row['files'] as $item) {
          $files[] = new File($item['name']);
        }
        
        $directory = new Directory($row['name']);
        $directory->reconstruct($row['isHidden'], ...$files);
    
        return $directory;
      }
    }
    
    /**
     * "Application/Directories/GetDirectoriesByOwner.php"
     * Это так называемый `Use Case` или в книжках `Application Service`.
     * Все наше приложение работает только через такие юз кэйсы, API, Console, Backend все что у нас есть вызывает такие юз кэйсы.
     * Пример:
     * $service = new GetDirectoriesByOwner(
     *   new Repository(
     *     new MySQLStorage($container[PDO::class])
     *   )
     * );
     */
    final class GetDirectoriesByOwner
    {
      /**
       * @var Repository
       */
      private $repository;
    
      /**
       * @param Repository $repository 
       */
      public function __construct(Repository $repository)
      {
        $this->repository = $repository;
      }
    
      /**
       * @param string $ownerId
       * 
       * @return array
       */
      public function handle(string $ownerId) : array
      {
        return $this->repository->getDirectoriesByOwner($ownerId);
      }
    }
    Ответ написан
    2 комментария