Анемичная модель предметной области (Anemic domain model) это такая модель, где сущности содержат только свойства, а бизнес-логика находится в сервисах. Ее противоположность это богатая модель предметной области (Rich domain model), где логика находится в сущностях, а cервиcы рекомендуют писать только в редких случаях.

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

Для бизнес-логики нужны зависимости, а их сложно пробросить в сущность, которая загружается из базы в произвольный момент во время выполнения. Также с Rich domain model в сущность помещаются все изменяющие ее бизнес-действия. Это приводит к тому, что сущность превращается в God-object, и код получается более сложный в поддержке.

Например, есть сущность "Order" с полем "status". У заказа может быть несколько десятков статусов, и на каждый статус есть свой сценарий, который его устанавливает. Значит в сущности будет несколько десятков методов. И это только одно поле. У товара кроме собственных полей обычно есть изображения, и логику их изменения в этом подходе тоже надо помещать в сущность "Product", так как она является aggregate root.

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

Бизнес-требования

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

Есть интернет-магазин. Есть отдельный сервис для поставщиков товаров, которые продают свои товары через интернет-магазин. Поставщики не являются сотрудниками интернет-магазина. Поставщик добавляет товары, редактирует данные, потом отправляет на проверку менеджерам интернет-магазина, которые работают в другой системе.
Менеджеры проверяют название, описание и другие параметры товара, и если все нормально, публикуют товар в интернет-магазине. Если какие-то данные не соответствуют требованиям, запрос отклоняется, и товар возвращается поставщику на редактирование. Пока товар на проверке, поставщику его редактировать нельзя.

Пока товар на редактировании у поставщика, любые поля кроме названия необязательны для заполнения. При отправке на проверку должны быть заполнены категория, название, и описание не менее 300 символов.
Все ошибки валидации желательно возвращать вместе, а не по одной.
Отправка товара на проверку в другую систему осуществляется вызовом API. Надо хранить историю отправок на проверку в нашей базе.
Отправлять надо только после успешного сохранения данных в нашу базу. В данных надо отправлять старое и новое значение поля, чтобы показывать красивый diff в интерфейсе.
Другая система иногда работает нестабильно, поэтому если вызов API не удался, надо это отображать в статусе запроса. Например, после успешной отправки ставить статус "Отправлено". Потом запрос переотправляется вручную по кнопке в админке.
Очереди? Да, очереди в плане, команда, которая ими занимается, займется нашим проектом через 2 месяца.

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

Также есть функциональность массовой загрузки данных через CSV и массовой отправки на проверку в виде консольных фоновых задач. В рамках примера мы ее реализовывать не будем, но надо учитывать, что изменение данных одного товара может происходить одновременно в 2 разных процессах.

Запрос на проверку это отдельная сущность, в коде она обозначается названием "Review". Предполагается, что пользователь имеет к ним доступ и может отменять по желанию. Название "ревью" на русском можно считать краткой версией названия "запрос на проверку".

Реализацию можно посмотреть в репозитории.

Реализация

Сущности

Product:
id            int
user_id       int
category_id   int
name          string
description   string
status        int
created_at    string

ProductChange:
product_id    int
field_values  json

Category:
id            int
name          string

Review:
id            int
user_id       int
product_id    int
status        int
field_values  json
created_at    string
processed_at  string

Создание товара

<?php

namespace frontend\controllers;

class ProductController
{
  public function actionCreate(): Response
  {
    $form = new CreateProductForm();
    $form->load($this->request->post(), '');

    if (!$form->validate()) {
        return $this->validationErrorResponse($form->getErrors());
    }

    $product = $this->productService->create($form, $this->getCurrentUser());

    return $this->successResponse($product->toArray());
  }
}


namespace frontend\services;

class ProductService
{
  public function create(CreateProductForm $form, User $user): Product
  {
    $product = new Product();

    $product->user_id = $user->id;
    $product->status = ProductStatus::HIDDEN->value;
    $product->created_at = DateHelper::getCurrentDate();

    $product->category_id = null;
    $product->name = $form->name;
    $product->description = '';

    $this->productRepository->save($product);

    return $product;
  }
}

Тут все стандартно, входное DTO с правилами валидации и сервис, который его обрабатывает. При создании заполняется только поле "name". Дальше будет интереснее.

Сохранение товара

Нужно учитывать 2 момента:
- пока товар на проверке, его запрещено редактировать;
- в фоне может работать задача массовой загрузки данных или массовой отправки на проверку, которую запустил пользователь, и прямо сейчас она собирается обработать этот товар.

<?php

class ProductController
{
  public function actionSave(int $id): Response
  {
    $product = $this->findEntity($id, needLock: true);

    $validationResult = $this->productService->isEditAllowed($product);
    if ($validationResult->hasErrors()) {
        return $this->validationErrorResponse($validationResult->getErrors());
    }

    $form = new SaveProductForm();
    $form->load($this->request->post(), '');
    if (!$form->validate()) {
        return $this->validationErrorResponse($form->getErrors());
    }

    $product = $this->productService->save($validationResult, $form);

    return $this->successResponse($product->toArray());
  }

  private function findEntity(int $id, bool $needLock): Product
  {
    $product = $this->productRepository->findById($id, $needLock);

    if ($product === null) {
        throw new NotFoundHttpException('Entity not found');
    }

    $isAccessAllowed = $product->user_id === $this->getCurrentUser()->id;
    if (!$isAccessAllowed) {
        throw new ForbiddenHttpException('Access denied');
    }

    return $product;
  }
}

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

Локи

Для предотвращения одновременной обработки используются локи.

<?php

class ProductRepository
{
  public function findById(int $id, bool $needLock): ?Product
  {
    if ($needLock) {
        $this->lockService->lock(Product::class, $id);
    }

    /** @var ?Product $product */
    $product = Product::find()->where(['id' => $id])->one();

    return $product;
  }
}

Метод lock() конкатенирует название класса и id и вызывает MySQL-функцию GET_LOCK(:str, :timeout) (документация).

Это мьютекс, он работает так. Первый процесс запрашивает мьютекс с определенным именем, мьютекс помечается занятым. Второй при запросе мьютекса с тем же именем будет ждать, пока он не освободится, но не дольше, чем указано в "timeout".
Мьютекс, запрошенный этой функцией, при закрытии подключения к БД освобождается автоматически.

В коде примеров явного освобождения нет, так как в PHP подключение к БД закрывается после обработки запроса. Если освобождать явно, то это надо делать в контроллере после вызова сервиса. Раньше нельзя, так как бизнес-логика еще не завершилась, позже нет смысла, это лишь задержит другие процессы, которые собираются работать с этим объектом.

Иногда можно использовать SQL-оператор FOR UPDATE, но он работает только внутри транзакции, а обработка может быть долгой и использовать сетевые вызовы, или требовать 2 раздельные транзакции.

Блокировать лучше только aggregate roots, иначе с этим будет сложно работать. То есть например Order, а не OrderItem.

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

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

Причина, почему в веб-приложениях так обычно не делают, заключается в том, что часто есть только один источник изменений некоторого ресурса. Пользователь создал заказ, он его больше не меняет. Одна система обработала заказ, поменяла статус, запускается другая система, ставит свой статус, потом третья. Вместе с одним заказом они не работают. Ресурсы, которые может пользователь может редактировать, например статьи или настройки профиля, он обычно не редактирует одновременно. Поэтому обычно все работает без локов. А когда не работает, появляется баг, все говорят "ну так бывает", кто-то исправляет данные, и на этом всё заканчивается до следующего раза.

История про локи

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

Поставщик отправил на ревью несколько десятков тысяч товаров. Товары отправлялись по одному, поэтому обработка продолжалась 12 часов. Для запуска консольных задач у нас использовалась внутренняя очередь. Там был настроен таймаут выполнения одной задачи 6 часов и retry 1 раз. Поэтому через 6 часов задача запустилась еще раз.

Так как список товаров был тот же самый, SQL-запросы в ней были абсолютно одинаковые. Первая задача прогрела разные внутренние механизмы БД в обоих системах, поэтому во второй раз они выполнялись немного быстрее, чем в первый. Через пару часов вторая задача догнала первую, и получился классический race condition.

Первая проверяет, что товар не отправлен, значит можно отправлять; вторая проверяет, что товар не отправлен, значит можно отправлять.
Первая отправляет список изменений; вторая отправляет список изменений.
Первая помечает товар отправленным; вторая помечает товар отправленным.

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

Валидация

Валидация сущности Product при сохранении делается методом isEditAllowed.

<?php

class ProductService
{
  public function isEditAllowed(Product $product): ProductValidationResult
  {
    $productValidationResult = new ProductValidationResult($product);

    if ($product->status === ProductStatus::ON_REVIEW->value) {
        $productValidationResult->addError('status', 'Product is on review');
    }

    return $productValidationResult;
  }
}

Нам нужно возвращать описание ошибки в виде текста, чтобы показать пользователю в интерфейсе, результат в виде true/false тут не подходит. Описание ProductValidationResult будет далее.

Логика сохранения

<?php

class ProductService
{
  public function save(ProductValidationResult $productValidationResult, SaveProductForm $form): ProductChange
  {
    $product = $productValidationResult->getProduct();
    $productChange = $this->productChangeRepository->findById($product->id);

    if ($productChange === null) {
        $productChange = new ProductChange();
        $productChange->product_id = $product->id;
    }

    $fieldValues = [];
    if ($form->category_id !== $product->category_id) {
        $fieldValues['category_id'] = $form->category_id;
    }
    if ($form->name !== $product->name) {
        $fieldValues['name'] = $form->name;
    }
    if ($form->description !== $product->description) {
        $fieldValues['description'] = $form->description;
    }
    $productChange->field_values = $fieldValues;

    $this->productChangeRepository->save($productChange);

    return $productChange;
  }
}

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

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

Просмотр

<?php

class ProductController
{
  public function actionView(int $id): Response
  {
    $product = $this->findEntity($id, needLock: false);
    $product = $this->productService->view($product);

    return $this->successResponse($product->toArray());
  }
}


class ProductService
{
  public function view(Product $product): Product
  {
    $productChange = $this->productChangeRepository->findById($product->id);

    $this->applyChanges($product, $productChange);

    return $product;
  }

  private function applyChanges(Product $product, ?ProductChange $productChange): void
  {
    if ($productChange !== null) {
        foreach ($productChange->field_values as $field => $value) {
            $product->$field = $value;
        }
    }
  }
}

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

У кого-то может появиться мысль поместить метод applyChanges() в сущность. В реальном приложении в Product будет штук 30 полей, изображения, файлы с инструкциями, эта обработка будет занимать несколько сотен строк, поэтому вряд ли это подходящее решение. Можно сделать отдельный компонент, или репозиторий, который по findById() будет возвращать объект с примененными изменениями.

Отправка на ревью

<?php

class ProductController
{
  public function actionSendForReview(int $id): Response
  {
    $product = $this->findEntity($id, needLock: true);

    $productValidationResult = $this->productService->isSendForReviewAllowed($product);
    if ($productValidationResult->hasErrors()) {
        return $this->validationErrorResponse($productValidationResult->getErrors());
    }

    $review = $this->productService->sendForReview($productValidationResult, $this->getCurrentUser());

    return $this->successResponse($review->toArray());
  }
}


class ProductService
{
  public function isSendForReviewAllowed(Product $product): ProductValidationResult
  {
    $productChange = $this->productChangeRepository->findById($product->id);
    $validationResult = new ProductValidationResult($product, $productChange);

    $newProduct = clone $product;
    $this->applyChanges($newProduct, $productChange);

    if ($newProduct->status === ProductStatus::ON_REVIEW->value) {
        $validationResult->addError('status', 'Product is already on review');
    } elseif ($productChange === null) {
        $validationResult->addError('id', 'No changes to send');
    } else {
        if ($newProduct->category_id === null) {
            $validationResult->addError('category_id', 'Category is not set');
        }
        if ($newProduct->name === '') {
            $validationResult->addError('name', 'Name is not set');
        }
        if ($newProduct->description === '') {
            $validationResult->addError('description', 'Description is not set');
        }
        if (strlen($newProduct->description) < 300) {
            $validationResult->addError('description', 'Description is too small');
        }
    }

    return $validationResult;
  }
}

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

Нам нужно не проверять изменения в ProductChange, а делать временную копию товара с примененными изменениями и проверять ее. Потому что записи с изменениями может вообще не быть, а в Product описание будет не заполнено.

Валидация сущности

<?php

class ProductService
{
  public function isSendForReviewAllowed(Product $product): ProductValidationResult
  {
      ...
  }

  public function sendForReview(ProductValidationResult $productValidationResult, User $user): Review
  {
      ...
  }
}


class ProductValidationResult
{
  ...

  public function __construct(?Product $product, ?ProductChange $productChange = null)
  {
    $this->product = $product;
    $this->productChange = $productChange;
  }

  public function addError(string $field, string $error): void
  {
    $this->product = null;
    $this->productChange = null;
    $this->errors[$field][] = $error;
  }

  public function hasErrors(): bool
  {
    return !empty($this->errors);
  }
  
  ...
}

ProductValidationResult нужен для передачи ошибок валидации сущности. Тут нет DTO c входными данными, которое можно провалидировать, сущность загружается из базы. Он хранит результат валидации и все загруженные данные, чтобы их не пришлось загружать в логике еще раз. Также он показывает другому программисту, что для вызова sendForReview() надо сначала сделать валидацию и получить ProductValidationResult. Если бы sendForReview() принимал Product, это было бы не так явно.

Логика отправки

Мы добрались до самого сложного метода.

<?php

class ProductService
{
  public function sendForReview(ProductValidationResult $productValidationResult, User $user): Review
  {
    $product = $productValidationResult->getProduct();
    $productChange = $productValidationResult->getProductChange();
    if ($productChange === null) {
        throw new RuntimeException('This should not happen');
    }

    $reviewFieldValues = $this->buildReviewFieldValues($product, $productChange);

    $review = new Review();
    $review->user_id = $user->id;
    $review->product_id = $product->id;
    $review->field_values = $reviewFieldValues;
    $review->status = ReviewStatus::CREATED->value;
    $review->created_at = DateHelper::getCurrentDate();
    $review->processed_at = null;

    $product->status = ProductStatus::ON_REVIEW;

    $transaction = $this->dbConnection->beginTransaction();
    $this->productRepository->save($product);
    $this->reviewRepository->save($review);
    $transaction->commit();

    $this->sendToAnotherSystem($review);

    $review->status = ReviewStatus::SENT->value;
    $this->reviewRepository->save($review);

    return $review;
  }

  private function buildReviewFieldValues(Product $product, ProductChange $productChange): array
  {
    $reviewFieldValues = [];
    $productFieldValues = $productChange->field_values;
    foreach ($productFieldValues as $key => $newValue) {
        $oldValue = $product->$key;
        $fieldChange = ['new' => $newValue, 'old' => $oldValue];
        $reviewFieldValues[$key] = $fieldChange;
    }

    return $reviewFieldValues;
  }

  private function sendToAnotherSystem(Review $review): void
  {
    $this->anotherSystemClient->sendReview($review);
  }

Нам нужно выполнить такие требования:
- Сохранить товар и ревью в нашу базу
- После успешного сохранения отправить ревью в другую систему
- После успешной отправки пометить ревью в нашей базе успешно отправленным

Мы хотим красиво показывать в интерфейсе что на что изменилось, поэтому надо записывать старое и новое значение.

Обратите внимание, отправка запроса к API это нетранзакционное взаимодействие, поэтому мы делаем сохранение данных в 2 шага - до отправки сохраняем данные с одним статусом в одной транзакции БД, отправляем данные, после отправки сохраняем с другим статусом в другой транзакции БД.

Если отправка в другую систему не удалась, объект Review останется в статусе CREATED, это можно будет отследить и скорректировать ошибку вручную. Например, показать в админке кнопку "Переотправить".

Именно вот эта логика "Сохранить - Отправить - Сохранить" на мой взгляд и является сложной для реализации в Rich domain model. У нас есть несколько строк подряд с вызовом сеттеров. Можно поместить их в сущность, но остальной код туда поместить нельзя, он должен быть где-то вне сущности. Делать коммиты транзакций БД это не ответственность сущности Product или Review. Для этого придется пробрасывать в сущность технические компоненты для работы с базой данных, что не соответствует назначению доменного слоя.

Бизнес-логика

А теперь важный момент. Это - не бизнес-логика.

<?php

class Review
{
  public function create(Product $product, ProductChange $productChange, User $user): void
  {
    $this->user_id = $user->id;
    $this->product_id = $product->id;
    $this->field_values = $this->buildReviewFieldValues($product, $productChange);
    $this->status = ReviewStatus::CREATED->value;
    $this->created_at = DateHelper::getCurrentDate();
    $this->processed_at = null;
  }
}

Это бизнес-логика.

class ProductService
{
  public function sendForReview(
    ProductValidationResult $validationResult,
    User $user,
  ): Review {
    [$product, productChange] =
      $this->getValidatedEntities($validationResult);

    // Сохранить товар и ревью в нашу базу
    $this->setFieldValues([$review, $product], $productChange, $user);
    $this->saveEntitiesInTransaction([$review, $product]);
    
    // После успешного сохранения отправить ревью в другую систему
    $this->sendToAnotherSystem($review);

    // После успешной отправки пометить ревью в нашей базе успешно отправленным
    $this->markAsSent($review);
    $this->saveEntitiesInTransaction([$review]);

    return $review;
  }
}

Код является прямым отражением бизнес-требований, записанных на естественном языке. То есть код содержит модель бизнес-требований.

Бизнес-логика - реализация правил и ограничений автоматизируемых операций.
бизнес-логика — это реализация предметной области в информационной системе.

Бизнес-логика это реализация бизнес-требований.

- Сохранить товар и ревью в нашу базу
- После успешного сохранения отправить ревью в другую систему
- После успешной отправки пометить ревью в нашей базе успешно отправленным

Обратите внимание, в бизнес-требованиях используются названия "товар" и "ревью". Если считать бизнес-требования описанием алгоритма действий, то эти названия являются обозначением переменных. Поэтому правильная программная модель бизнес-требований должна содержать переменные "product" и "review", а никакой не "this". Бизнес не обсуждает, как вы будете устанавливать значения свойств сущности.

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

Принятие ревью

<?php

namespace internal_api\controllers;

class ReviewController
{
  public function actionAccept(int $id): Response
  {
    $review = $this->findEntity($id, needLock: true);

    $review = $this->reviewService->accept($review);

    return $this->successResponse($review->toArray());
  }
}


namespace internal_api\services;

class ReviewService
{
  public function accept(Review $review): Review
  {
    if ($review->status !== ReviewStatus::SENT->value) {
        throw new RuntimeException('Review is already processed');
    }

    $product = $this->productRepository->findById($review->product_id, needLock: true);

    $transaction = $this->dbConnection->beginTransaction();

    $this->saveReviewResult($review, ReviewStatus::ACCEPTED);
    $this->acceptProductChanges($product, $review);

    $transaction->commit();

    return $review;
  }

  private function saveReviewResult(Review $review, ReviewStatus $status): void
  {
    $review->status = $status->value;
    $review->processed_at = DateHelper::getCurrentDate();
    $this->reviewRepository->save($review);
  }

  private function acceptProductChanges(Product $product, Review $review): void
  {
    foreach ($review->field_values as $field => $fieldChange) {
        $newValue = $fieldChange['new'];
        $product->$field = $newValue;
    }
    $product->status = ProductStatus::PUBLISHED;
    $this->productRepository->save($product);

    $this->productChangeRepository->deleteById($product->id);
  }
}

Переносим изменения из ревью в товар и удаляем запись с изменениями. Ставим нужные статусы в сущностях.

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

Обратите внимание, тут ReviewController это отдельный контроллер для внутреннего API, которое может быть на отдельном домене и недоступно для пользователя. То есть в пространстве имен "frontend" этот метод вообще не существует. Действия с сущностью разделяются на независимые группы, а не находятся в одном большом классе.

Отмена ревью

<?php

class ReviewController
{
  public function actionDecline(int $id): Response
  {
    $review = $this->findEntity($id, needLock: true);

    $review = $this->reviewService->decline($review);

    return $this->successResponse($review->toArray());
  }
}


class ReviewService
{

  public function decline(Review $review): Review
  {
    if ($review->status !== ReviewStatus::SENT->value) {
        throw new RuntimeException('Review is already processed');
    }

    $product = $this->productRepository->findById($review->product_id, needLock: true);

    $transaction = $this->dbConnection->beginTransaction();

    $this->saveReviewResult($review, ReviewStatus::DECLINED);
    $this->declineProductChanges($product);

    $transaction->commit();

    return $review;
  }

  private function declineProductChanges(Product $product): void
  {
    $product->status = ProductStatus::HIDDEN;
    $this->productRepository->save($product);

    $this->productChangeRepository->deleteById($product->id);
  }
}

Просто удаляем запись с изменениями. Ставим нужные статусы в сущностях.

Размышления на тему

Разное понимание

Знатоки DDD и Clean Architecture могут сказать, что кроме сущностей нужны так называемые Use Cases, где и будут коммиты транзакций, вызовы других сервисов, и прочие вещи. Так и есть. Дело в том, люди, которые не знакомы с DDD и Clean Architecture, называют их просто сервисы. В этом, как мне кажется, и есть причина взаимного непонимания. Потом кто-то встречает статью Фаулера про Anemic Domain Model, где он говорит, что в сервисах логики быть не должно, и начинаются попытки ее оттуда убрать.

Я встречал много ситуаций, когда принцип "логика должна быть в сущностях" возводят в абсолют и помещают в сущность то, что должно быть в юзкейсах, из-за чего код постепенно усложняется и становится неподдерживаемым. Например, мне советовали сделать в сущности публичное поле "sentEmails", чтобы хранить там письма, созданные бизнес-действием, потом забирать их специальным кодом и отправлять фактически.

Другой пример - фильтры на странице списка сущностей. Это именно бизнес-логика, работа полей обсуждается на уровне бизнеса. Например, "Если введено значение в поле фильтра "Текст", надо проверять на наличие текста с полным совпадением следующие поля товара: название, описание, SKU, производитель". Эту логику тоже нельзя поместить в сущность, со значениями полей отдельного объекта она никак не связана.

Логику фильтров часто помещают в репозиторий. Репозиторий не должен содержать бизнес-логику, это не его ответственность. К тому же для страницы списка требуется количество страниц или общее количество записей, а от репозитория обычно ожидается просто массив объектов. Поэтому правильно помещать эту логику в сервис, который будет обрабатывать фильтры, сортировку и пагинацию в соответствии с данными из HTTP-запроса и возвращать все нужные данные для отображения страницы, а не только массив сущностей. Сервис будет использовать Query Builder, чтобы настроить запрос к хранилищу данных и передать его в репозиторий.

Распространенные аргументы

Хотелось бы прокомментировать некоторые распространенные аргументы, которые приводят в пользу логики в сущностях.

Свойства сущности это детали ее реализации, которые нужно скрывать.

Это не так. Если бы свойства сущности были деталями ее реализации, вы бы никогда про них не узнали при анализе предметной области. Вы наблюдаете сущность "Товар" со стороны и видите у нее свойства (X, Y, Z), значит они публично доступны для вас как наблюдателя со стороны.

Детали реализации это то, как мы храним эти свойства. Например, в ActiveRecord поле "name" может храниться как $this->data['name']. Поэтому $entity->data['name'] это детали реализации, а $entity->name с магическим методом __get() нет. Эти детали раскрывать не надо, а существование свойства можно и нужно, так код будет содержать правильную модель предметной области.

Никто не сможет привести сущность в невалидное состояние без вызова моих проверок.

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

Сущность должна сама проверять свои инварианты.

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

Что моделирует сервис?

Может появиться вопрос - если сервис это часть доменного слоя, то что он моделирует?

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

Цель статьи

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

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

Большинство бизнес-сущностей пассивны по своей природе (документ сам себя не заполняет), поэтому анемичная модель это следствие бизнес-требований. Любая бизнес-инструкция с технической точки зрения является процедурой, которая манипулирует некоторым набором сущностей.

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

Преимущества этого подхода:
- Реализация получается максимально близкой к бизнес-требованиям, что упрощает поддержку.
- Зависимости пробрасываются только в конструктор сервиса, в сигнатуре методов используются только бизнес-типы.
- Логика разделена на группы, нет классов, которые содержат все возможные бизнес-действия.
- Не используются исключения для возврата результатов валидации или выполнения логики.
- Логика, валидация, сериализация и протокол передачи данных отделены друг от друга.
- Подход совместим с любыми технологиями. В статье используется Yii, но можно написать в таком же стиле на компонентах Symfony.

Желающие могут попробовать реализовать аналогичное поведение в соответствии со своим видением подхода с логикой в сущностях.

Репозиторий

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

Комментарии (69)


  1. powerman
    18.03.2024 21:10
    +6

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

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

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

    Также с Rich domain model в сущность помещаются все изменяющие ее бизнес-действия. Это приводит к тому, что сущность превращается в God-object, и код получается более сложный в поддержке.

    Не превращается - почитайте определение God object. Получается вполне обычный объект, который знает только про себя и контролирует свои инварианты. Если у Вас такая сущность начала разрастаться и затягивать в себя вообще все сущности домена - это говорит только о том, что Вы проигнорировали первое правило агрегатов DDD: делать их как можно меньше.

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

    Не обязательно, но в целом - да, как-то так. Если у бизнеса есть десятки операций с заказом, то для каждой операции будет отдельный метод. Плюс DDD в том, что все методы изменяющие заказ будут объявлены на одном типе Order, а не размазаны по всему коду проекта - это сильно упрощает ревью на корректность всей бизнес-логики изменения заказа.

    DDD говорит, что можно делать сервисы с логикой когда используются несколько сущностей, например перевод денег с аккаунта на аккаунт. Одна сущность это частный случай N сущностей, поэтому сервисы для него тоже прекрасно подходят.

    Это прекрасный пример софистики. На всякий случай: ещё DDD говорит использовать доменные сервисы как можно меньше и прибегать к ним только если операцию нет возможности адекватно реализовать в методе одного конкретного объекта (как раз потому, что не очевидно, что она принадлежит именно этому объекту, т.к. в ней задействованы и другие).

    Никакая логика не принадлежит сущности

    Этой идее больше лет, чем ООП. Называется: процедурная парадигма программирования. Её плюсы и минусы давно известны, причём минусы достаточно значительны, что и привело к появлению ООП.

    Потом кто-то встречает статью Фаулера про Anemic Domain Model, где он говорит, что в сервисах логики быть не должно, и начинаются попытки ее оттуда убрать.

    Есть такое, но это просто от непонимания сути вещей и слепого доверия авторитетам. Анемичная модель не является анти-паттерном в общем смысле. Она является анти-паттерном в DDD. Если проект пишется не по DDD, а как Transaction Script, то анемичная модель вполне корректный паттерн.

    Другой пример - фильтры на странице списка сущностей. Это именно бизнес-логика, работа полей обсуждается на уровне бизнеса.

    Не в DDD. Когда DDD обсуждает Rich Domain Model то под бизнес-логикой подразумевается исключительно то, что изменяет эти модели. В терминологии CQRS - речь исключительно о командах. Запросы же, которые не изменяют сущность, в DDD рекомендуется делать ровно так же, как и в Transaction Script: грузить прямо из БД специализированными запросами в анемичную модель, сформированную под конкретный use case (т.е. фактически - под текущие нужды UI).

    Логику фильтров часто помещают в репозиторий. Репозиторий не должен содержать бизнес-логику, это не его ответственность.

    См. выше. В данном случае, по DDD, это не бизнес-логика и ей самое место в репозитории.

    Свойства сущности это детали ее реализации, которые нужно скрывать.

    Речь не о том, что их нельзя прочитать, а о том, что их не должно быть возможности изменить снаружи. Возьмите любой пример из книжки IDDD - там все свойства обычно объявляются { get; private set; }.

    Никто не сможет привести сущность в невалидное состояние без вызова моих проверок.

    Ну как это не сможет. Создаем в сущности новый метод, там устанавливаем свойства как нам нужно.

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

    Сущность должна сама проверять свои инварианты.

    Часто нет таких правил, которые должны соблюдаться во всех сценариях.

    Давайте я начну слово, а вы сами его договорите: со-фи-…! Из того, что бывают требования специфичные не для модели, а для конкретного use case, никак не следует отсутствие требований, которые специфичны именно для модели и должны соблюдаться именно всегда. Поэтому одни разумно реализовать методами модели, а другие в доменных сервисах.

    Что моделирует сервис?

    Может появиться вопрос - если сервис это часть доменного слоя, то что он моделирует?

    Ничего. Именно поэтому в DDD доменные сервисы должны быть stateless. У них нет собственных данных. По сути сама идея доменного сервиса именно как "сервиса" во многом вызвана ограничениями конкретных ООП языков того времени, которые не поддерживали другие парадигмы кроме ООП, из-за чего в них не было "просто функций". Поэтому и понадобился "доменный сервис", как пустой объект без данных, который позволил объявить на себе группу обычных функций как методы этого пустого объекта. В мультипарадигменных языках вполне можно вместо доменного сервиса использовать обычные функции, суть DDD от этого никак не пострадает.

    Цель статьи

    Полагаю: Chaos, panic and disorder - my work here is done! ©


    1. michael_v89 Автор
      18.03.2024 21:10

      почитайте определение God object

      https://en.wikipedia.org/wiki/God_object

      "In object-oriented programming, a god object (sometimes also called an omniscient or all-knowing object) is an object that references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both."

      Вот про "too many methods" я и говорю. Иначе можно вообще всю логику программы поместить в один класс и сказать, что программа контролирует свои инварианты.

      это говорит только о том, что Вы проигнорировали первое правило агрегатов DDD: делать их как можно меньше

      А еще надо писать код всегда без ошибок. Это все абстрактные пожелания, вы покажите код. Я же привел пример, есть сущность "Заказ" с 30 статусами, "статус" это одно поле, куда еще меньше-то?)

      Плюс DDD в том, что все методы изменяющие заказ будут объявлены на одном типе Order, а не размазаны по всему коду проекта

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

      Это прекрасный пример софистики.

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

      Называется: процедурная парадигма программирования.
      что и привело к появлению ООП

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

      Она является анти-паттерном в DDD.

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

      Когда DDD обсуждает Rich Domain Model то под бизнес-логикой подразумевается исключительно то, что изменяет эти модели.

      Вот я и указываю на то, что это некорректно. Бизнес-требования к работе фильтров это такие же бизнес-требования, а их реализация это бизнес-логика.

      В данном случае, по DDD, это не бизнес-логика и ей самое место в репозитории.

      У бизнес-логики есть общепринятое определение, это не термин из DDD.

      Речь не о том, что их нельзя прочитать, а о том, что их не должно быть возможности изменить снаружи.

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

      вообще весь код проекта, или только код методов этой сущности.

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

      Поэтому одни разумно реализовать методами модели, а другие в доменных сервисах.

      Ну и как вы одни от других отличаете? Требования они и есть требования. Сегодня они общие для всех сценариев, завтра бизнес захотел добавить сценарий без них, а потом появляются истории, что из-за небольшого требования надо переписать пол-проекта.

      Что он моделирует? Ничего. Именно поэтому в DDD

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

      Полагаю: Chaos, panic and disorder - my work here is done!

      Я же написал - показывайте свою реализацию, сравним и обсудим. Там всего 6 методов, коммент писать дольше. Обзывательства без кода считаю бессмысленным и неконструктивным подходом.


      1. michael_v89 Автор
        18.03.2024 21:10

        набор сеттеров можно поместить в сущность

        Уточню, что тут под сеттером я подразумеваю выражение вида $this->name = $name, а не метод setName().


      1. mike_shapovalov
        18.03.2024 21:10
        +1

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

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


        1. michael_v89 Автор
          18.03.2024 21:10

          Объект в понимании ООП это объединение состояния и поведения

          Я же написал - да, ООП это объединение состояния и поведения, только этот код бизнес-логики это не поведение отдельной сущности. Пример - бизнес-логика вида "сохранить с одним статусом, оплатить заказ, сохранить с другим статусом". Подумайте, почему мы не объединяем состояние и поведение в случае перевода с аккаунта на аккаунт? Тут ведь тоже состояние меняется.

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

          Поведение касается деталей реализации объекта. Например, класс какого-нибудь парсера, который хранит внутреннее состояние, которое меняется в процессе работы метода parse(). Или класс для вычисления хеша, который обновляет текущий хеш при поступлении новых данных. Вы не знаете, как он устроен, какие внутренние переменные он хранит, вы получаете только финальный результат. А про сущность знаете, эту информацию мы получаем при анализе предметной области. "Поведение" это не возможность вызвать метод setName(), а то, как именно он работает, какие внутренние свойства меняет. Может он сохраняет значение в $this->_name, может в $this->data['name']. А факт того, что у сущности можно установить свойство "name" по определенным правилам, идет из предметной области.


          1. mike_shapovalov
            18.03.2024 21:10
            +1

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


            1. michael_v89 Автор
              18.03.2024 21:10

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

              Вопрос про аккаунты вы почему-то проигнорировали.


              1. mike_shapovalov
                18.03.2024 21:10

                Проверка можно ли из статуса "Ожидает оплаты" перейти в статус "Доставлен" где по вашему должна происходить?


                1. michael_v89 Автор
                  18.03.2024 21:10

                  Хорошим тоном является сначала ответить на вопросы, прежде чем задавать свои.

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

                  Любая бизнес-инструкция с технической точки зрения является процедурой, которая манипулирует некоторым набором сущностей.


                  1. mike_shapovalov
                    18.03.2024 21:10

                    Любая бизнес-инструкция с технической точки зрения является процедурой, которая манипулирует некоторым набором сущностей.

                    Тогда почему вы не хотите признать, что используете процедурное программирование, а не ООП? :)


                    1. michael_v89 Автор
                      18.03.2024 21:10

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


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Потому что в данном случае ООП тоже используется в нужном объеме. 

                        В слое бизнес логики не используется.

                        Кроме того, это было сказано как технический недостаток и неправильная модель предметной области, с чем я не согласен

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


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        В слое бизнес логики не используется.

                        Я ответил на это выше. Поведение помещено в тот объект, которому оно принадлежит, просто это не сущность. Если в ООП можно помещать методы в классы объектов, это не значит, что в классы можно помещать любые методы. В очередной раз прошу вас сформулировать ответ, почему перевод с аккаунта на аккаунт не является поведением сущности "Аккаунт".


                      1. mike_shapovalov
                        18.03.2024 21:10

                        <?php
                        final class TransferService
                        {
                            public function transferMoney(Uuid $sourceAccountId, Uuid $targetAccountId, Money $amount): void
                            {
                                $sourceAccount = $this->accountRepository->get($sourceAccountId);
                                $targetAccount = $this->accountRepository->get($targetAccountId);
                                $sourceAccount->withdraw($amount);
                                $targetAccount->deposit($amount);
                            }
                        
                        }

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


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Ну а transferMoney-то почему не в сущности Account? Можно же вот так написать.

                        <?php
                        class Account
                        {
                            public function transferMoney(Account $targetAccount, Money $amount): void
                            {
                                $this->withdraw($amount);
                                $targetAccount->deposit($amount);
                            }
                        }
                        

                        Последовательность "withdraw, deposit" это и есть бизнес-логика перевода, и она аккаунту не принадлежит.


                      1. mike_shapovalov
                        18.03.2024 21:10
                        +1

                        Плохо читали теорию DDD. Один агрегат не может напрямую управлять состоянием другого.


                      1. michael_v89 Автор
                        18.03.2024 21:10

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


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Чтобы уменьшить сложность и не нарушать границы согласованности транзакций, но это вовсе не означает что агрегаты должны быть анемичными.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Ну вот, начались мало формализуемые критерии. А почему сложность-то увеличивается, если поместить эти 2 строки в сущность?)

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

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


                      1. mike_shapovalov
                        18.03.2024 21:10
                        +1

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

                        Так я и говорю, состояние в одном месте, поведение в другом. Процедурное программирование.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Нет, поведение по управлению состоянием объекта находится в объекте.

                        <?php
                        
                        class Product {
                            private string $_name;
                        
                            public setName(string $name) {
                              $this->_name = $name;
                            }
                        }
                        
                        $product->setName('test');
                        
                        // ------
                        
                        /** @property string $name */
                        class Product {
                            private array $data;
                        
                            public __set(string $field, mixed $value) {
                              $this->data[$field] = $value;
                            }
                        }
                        
                        $product->name = $test;
                        

                        В обоих случаях детали реализации поведения скрыты в сущности, наружу она предоставляет свойство "name", а не "_name" или "data".

                        Статья как раз о том что многие совершают эту ошибку, считают бизнес-логику или наличие свойств деталью реализации.


                      1. mike_shapovalov
                        18.03.2024 21:10
                        +2

                        Нет, поведение по управлению состоянием объекта находится в объекте.

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

                        <?php
                        final class Account
                        {
                            
                            private $blocked = false;
                            private Money $balance;
                        
                            public function withdraw(Money $amount): void
                            {
                                if ($this->balance->isLessThan($amount)) {
                                    throw new InssuficientFundsException();
                                }
                                if ($this->blocked) {
                                    throw new AccountBlockedException();
                                }
                                $this->balance = $this->balance->subtract($amount);
                            }
                        }
                        


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Это не поведение, в контексте бизнес логики

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

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

                        с таким же успехом я могу сделать все эти свойства публичными.

                        Нет, тогда если вы переименуете "_name" в "name" в первом примере или замените "data" на отдельный свойства во втором, вам придется менять это по всему коду. Именно это и было целью ООП - скрыть детали реализации объекта, чтобы в этом не было необходимости.

                        Вот поведение:

                        Ага, клево. Теперь бизнес к вам приходит и говорит "Сделайте мне овердрафт, лимит которого устанавливается в договоре". Что вы будете делать, пробрасывать Agreement в withdraw и isLessThan? Что там ООП говорит про одну причину для изменения?

                        Другой вопрос, бизнес к вам приходит и говорит "Сделайте чтобы в форме перевода показывались ошибки сразу от обоих аккаунтов". Что вы будете делать, дублировать валидацию без эксепшенов?


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Ага, клево. Теперь бизнес к вам приходит и говорит "Сделайте мне овердрафт, лимит которого устанавливается в договоре". Что вы будете делать, пробрасывать Agreement в withdraw и isLessThan? Что там ООП говорит про одну причину для изменения?


                        <?php
                        final class Account
                        {
                        
                            private $blocked = false;
                            private Money $balance;
                        
                            private OutsideInterface $outside;
                        
                            public function withdraw(Money $amount): void
                            {
                                if ($this->balance->isLessThan($this->getAvailableAmount())) {
                                    throw new InssuficientFundsException();
                                }
                                if ($this->blocked) {
                                    throw new AccountBlockedException();
                                }
                                $this->balance = $this->balance->subtract($amount);
                            }
                        
                            private function getAvailableAmount() {
                                return $this->balance->add($this->outside->getOverdraftAmount($this->id));
                            }
                        }

                        Другой вопрос, бизнес к вам приходит и говорит "Сделайте чтобы в форме перевода показывались ошибки сразу от обоих аккаунтов". Что вы будете делать, дублировать валидацию без эксепшенов?

                        Это уже требования UI а не бизнес логики. В этом случае чтобы избежать дублирования, логику проверки вынесу в чистую функцию, и буду вызываеть ее изнутри доменного объекта и в тех местах UI где нужна такая валидация:

                        <?php
                        function hasEnoughFunds(Money $amount, Money $balance, Money $overdraft): bool
                        {
                            return $balance->add($overdraft)->isGreaterOrEqualThan($amount);
                        }


                        "Поведение объекта" это более низкоуровневое понятие, чем бизнес-логика,

                        Мы говорим о ООП в контексте бизнес логики, а то поведение что вы продемонстировали никакого отношения к бизнес логике не имеет.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        $this->outside->getOverdraftAmount()

                        Что еще за "outside", в бизнес-требованиях нет такого термина) Потом этот код открывает другой программист и полчаса разбирается, чему в бизнес-требованиях оно соответствует. Что там было про Ubiquitous language?

                        <?php
                        
                        // TransferService
                        public function isTransferAllowed(Account $sourceAccount, Account $targetAccount, Money $amount) {
                          ...
                          $agreement = $this->loadAgreement();
                        
                          // если баланс будет меньше лимита в договоре, не разрешать перевод
                        
                          $potentialFinalAmount = $sourceAccount->balance->substract($amount);
                          if ($potentialFinalAmount < $agreement->getOverdraftAmount()) {
                            return false;
                          }
                          ...
                        }
                        

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

                        Мы говорим о ООП в контексте бизнес логики

                        Термины ООП не меняются от того, где оно применяется. В ООП поведение это методы, которые работают с состоянием объекта. Метод setName() работает с состоянием, значит это поведение. В контексте бизнес логики можно говорить только о правильной или неправильной модели.

                        Это уже требования UI а не бизнес логики.

                        Для UI нужна информация, а ее получение это бизнес-логика. Информация о том, почему переводить нельзя, зависит от бизнес-требований к переводу.

                        логику проверки вынесу в чистую функцию

                        Вау, вы же только что сказали, что это поведение объекта и должно быть внутри класса. Теперь уже не поведение?)
                        Вот так постепенно вы и вынесете всё в чистые функции.


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Вау, вы же только что сказали, что это поведение объекта и должно быть внутри класса. Теперь уже не поведение?)

                        Поведение остается внутри объекта, вы не можете обойти эту проверку при изменении состояния объекта. Можно кстати эту функцию оставить и внутри класса сделать ее публичной и вызывать ее из UI если вам так больше нравиться.

                        Что еще за "outside", в бизнес-требованиях нет такого термина) Потом этот код открывает другой программист и полчаса разбирается, чему в бизнес-требованиях оно соответствует. Что там было про Ubiquitous language?

                        Там также нет и термина репозиторий, фабрика, сервис и многих других, что не мешает их использовать в слое домена. Outside это враппер который абстрагирует внешние зависимости агрегата, если интересно тут этот подход описан подробнее https://habr.com/ru/articles/799019/
                        Если вам так будет понятнее можно назвать метод getOverdraftAmountFromClientAgreement

                        Есть код, там меньше 300 строк логики на все методы, напишите хотя бы 2 полностью вместе с контроллером и валидацией, тогда будем сравнивать

                        Не вижу смысла обсуждать такие тривиальныt вещи как слой UI в контексте обсуждение богатой и анемичной модели.

                        Метод setName() работает с состоянием, значит это поведение

                        Инфраструктурного объекта возможно, но никак не доменного.

                        Для UI нужна информация, а ее получение это бизнес-логика. Информация о том, почему переводить нельзя, зависит от бизнес-требований к переводу.

                        Как уже заметил @powerman не в контексте DDD и богатой модели. В DDD бизнес логикой считается только та логика которая происходит в момент изменения состояния системы. Если вам нужно это состояние получить до изменения, вы используете запрос а не команду.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Поведение остается внутри класса, вы не можете обойти эту проверку при изменении состояния объекта.

                        Да ну как это не могу-то)

                        class Account
                        {
                          public function withdraw() {
                            if (hasEnoughFunds()) {
                              throw new InssuficientFundsException();
                            }
                            
                            $this->balance = $this->balance->subtract($amount);
                          }
                        
                          public function withdrawWithoutCheck() {
                            $this->balance = $this->balance->subtract($amount);
                          }
                        }
                        

                        Outside это враппер который абстрагирует внешние зависимости агрегата

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

                        Не вижу смысла обсуждать такие тривиальные вещи как слой UI в контексте обсуждение богатой и анемичной модели.

                        Смысл в том, что бизнесу нужно законченное приложение, а не богатая модель. В этом и есть цель статьи, рассмотреть все аспекты, потому что их часто упускают, а потом в реальных приложениях появляются сложности. Работы с UI там вообще нет, есть только JSON-ответ со списком ошибок валидации.

                        не в контексте DDD и богатой модели.

                        Вот я как раз и объясняю, что DDD считает некорректно, из-за чего и возникают сложности. Просто понятие "бизнес-логика" это не термин из DDD, поэтому и статья не конкретно о DDD.


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Да ну как это не могу-то)
                        public function withdrawWithoutCheck() {

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

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

                        Это почему же? Есть операция списание средств со счета. Данная операция изменяет только состояние агрегата счет. Перед изменением этого состояния объект счет должен выполнить ряд проверок чтобы убедиться что такое изменение возможно. Часть данных для этих проверок находится за пределами агрегата, поэтому он их запрашивает из внешнего мира (outside). Если алгоритм списания измениться то это изменение произойдет только внутри агрегата Счет, не затрагивая другие агрегаты. У агрегата счет одна ответственность, контроль всех операций которые изменяют его состояние.

                        Работы с UI там вообще нет, есть только JSON-ответ со списком ошибок валидации.

                        Вы похоже не понимаете что такое слой UI в бэкенд приложении и за что этот слой отвечает.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Только никто в здравом уме не станет такой метод писать, при наличии в объекте метода с проверкой.

                        Ну вот и с сервисами так же.

                        потому что просто не знает что есть сервис с такой проверкой, гораздо выше

                        Когда в сущности 3000 строк и пара сотен методов, не знать про нужный метод или пропустить его тоже очень вероятно.
                        Про сервисы не надо знать, нажимаем "Find usages" в IDE и получаем все места, где используется сущность.

                        то вероятность что кто-то в каком то сервисе напрямую модифицирует баланс без всяких проверок

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

                        Это почему же?
                        Часть данных для этих проверок находится за пределами агрегата

                        Вот именно потому что часть данных находится за пределами агрегата. Если они нужны сущности для изменения состояния, их надо передавать в аргументах метода. Загружать данные это не ответственность сущности.

                        Вы похоже не понимаете что такое слой UI в бэкенд приложении и за что этот слой отвечает.

                        Это вы не понимаете, раз считаете это неважной частью. Я бы еще понял, если бы вы говорили про работу с HTML, но здесь речь про получение данных. Неважно какой это слой, эта информация связана с бизнес-логикой, поэтому ее тоже надо рассматривать, что я и предложил. Потому что в этом и был весь смысл. А вы говорите "Ну если вот эти сложности убрать, то будет просто". Ну так с этим никто и не спорил, просто в реальном приложении их убрать нельзя.


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Когда в сущности 3000 строк и пара сотен методов, не знать про нужный метод или пропустить его тоже очень вероятно.

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

                        Про сервисы не надо знать, нажимаем "Find usages" в IDE и получаем все места, где используется сущность

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

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

                        Если не забудет :).

                        Вот именно потому что часть данных находится за пределами агрегата. Если они нужны сущности для изменения состояния, их надо передавать в аргументах метода. Загружать данные это не ответственность сущности.

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

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

                        Важно, ответственность слоя UI ограничивается преобразованием данных в понятный для слоя приложения формат, и передача их в слой приложения и обратно. Никакой бизнес логики там быть не должно. Вся бизнес логика должна находится в слое домена и реализована либо в процедурном стиле (Amemic model) либо при помощи ООП (Rich model). Но в большинстве приложений, как правило, нету четкого разделения слоев и парадигм, обычно там бешеная смесь в которой трудно что либо понять.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Именно для этого в DDD и придуманы ограниченные контексты.

                        Да-да, я ждал, когда про них кто-нибудь вспомнит. В этом примере у нас есть CustomerOrder и ShippableOrder, и в ShippableOrder при желании можно удалить один item, не оглядываясь на ограничения CustomerOrder. Это мало чем отличается от сервисов, а также противоречит вашим словам о преимуществе "Все инварианты внутри класса", потому что теперь у нас 2 класса.
                        Кстати, в том примере используются сервисы.

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

                        Это не проще, не сложнее, а так же.

                        Если не забудет :)

                        Как и с сущностями. Если мне нужна новая логика, то с логикой в сущности я должен добавить новый метод и там вызывать что мне нужно.

                        Она их и не загружает, а всего лишь объявляет какие данные ей нужны через интерфейс

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

                        но ничего не знает откуда и как эти данные загружаются

                        Я знаю как работают интерфейсы, в контексте моего утверждения это не имеет значения.

                        ответственность слоя UI ограничивается преобразованием данных

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


                      1. mike_shapovalov
                        18.03.2024 21:10
                        +1

                        Ок, вы не понимаете преимущества ограниченных контекстов, инкапсуляции, принципа inversion of control, CQRS и похоже мне не удасться до вас эту информацию донести. Если вас устраивает подход который вы описали в статье, и он позволят вам создавать качественные, поддерживаемые приложения, то это очень хорошо и я могу только за вас порадоваться. Но я бы на вашем месте не стал бы так категорично объявлять этот подход лучше тех подходов которые вы не понимаете.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Из того, что я с вами не согласен, не следует, что я не понимаю преимущества. В рамках DDD ограниченные контексты имеют преимущество, потому что решают проблему, которая без логики в сущностях не возникает. С сервисами мы просто сразу делаем разные сервисы, пример есть в статье.

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


                      1. mike_shapovalov
                        18.03.2024 21:10

                        Я вам уже показал преимущества которое я вижу в использовании богатой модели в слое домена, на примере операции списания средств со счета, вас этот пример не убедил, вы преимуществ в объединении поведения и состояния не видите. Реализация слоя UI с моей точки зрения ничего принципиально не изменит, поэтому не вижу смысла тратить на это время. Если для вас подход с сервисами и анемичными моделями работает - отлично, я лично вижу в этом подходе ряд недостатков, которые озвучил. Больше мне добавить нечего.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Я вам уже показал преимущества на примере операции списания средств со счета

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


          1. powerman
            18.03.2024 21:10
            +1

            По ООП у класса должна быть одна причина для изменения

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


            1. michael_v89 Автор
              18.03.2024 21:10

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

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


  1. pkokoshnikov
    18.03.2024 21:10
    +1

    Могу посоветовать вам хорошую книгу "Learning Domain-Driven Design" Vlad Khononov, чтобы лучше понять концепции DDD. А так согласен с автором комментария выше. Нет понимания DDD. Разбирать это конечно слишком долго все.


    1. michael_v89 Автор
      18.03.2024 21:10

      Я читал книгу Vaughn Vernon, имею некоторое представление о концепциях.
      Статья не конкретно о DDD, а о любых пониманиях логики в сущности. Добавил эту информацию в статью. Все понимают по-разному и пытаются ее туда поместить, а это в целом менее правильный подход, он дает менее правильную модель бизнес-требований. Начинать надо с сервисов, даже если они называются юзкейсами, а что поместить в сущность это не так уж важно.

      Согласен, статья большая получилась, но эти 6 методов на мой взгляд это минимум, на маленьких учебных примерах сложно оценить проблемы и задачи, которые возникают в реальной разработке. Код прочитать проще, если есть желание попробуйте написать хотя бы один метод sendForReview с логикой в сущности, можно будет говорить более предметно.


      1. powerman
        18.03.2024 21:10

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

        Что до анемичных моделей и вынесения бизнес-логики отдельно от моделей - это гораздо более распространённый подход чем DDD, который обычно называют Transaction Script. Он прекрасно работает, пока в одном Bounded Context не оказывается реально много сложной бизнес-логики (что бывает довольно редко). К сожалению, Ваш стиль изложения сильно усложнил попытку понять, чем Ваш подход отличается (если отличается) от Transaction Script.


        1. michael_v89 Автор
          18.03.2024 21:10

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

          Примеры пожалуйста, со ссылками на общепринятый смысл и отличиями. С моей точки зрения я использую все термины в общепринятом смысле.

          чем Ваш подход отличается (если отличается) от Transaction Script

          Да неважно, Transaction Script это или что-то еще. Основная мысль, что из контроллера или консольной команды должен вызываться какой-то класс с точкой входа в логику, который не сущность. Реализовать это можно по-разному, но этот класс должен быть. Я вижу, что во многих случаях его пытаются убрать и поместить в сущность вообще всё, со всеми зависимостями. От этого возникают проблемы, которые пытаются решить разными способами, например, передавать зависимости в методы сущности по интерфейсу.


          1. powerman
            18.03.2024 21:10
            +1

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

            Ну, на первый взгляд звучит как Transaction Script. Часть бизнес-логики при таком подходе может оказаться не только в этом классе (в слое приложения/use cases, если в терминах Clean Architecture), но и в слое инфраструктуры (в реализации репозитория - та часть, которую необходимо делать в рамках транзакции БД). Слой домена при таком подходе может вообще отсутствовать (то, что называется слоем домена в терминах Clean Architecture, в терминах DDD называется Shared Kernel и это не совсем "слой").

            Я вижу, что во многих случаях его пытаются убрать и поместить в сущность вообще всё

            Сложно угадать что именно Вы видели и почему оно было реализовано именно так.

            Иногда всю бизнес-логику пытаются перенести из слоёв приложения и инфраструктуры в слой домена (и да, большую её часть именно в методы модели). По сути - перейти от Transaction Script к DDD. Делают это обычно тогда, когда бизнес-логика становится насколько сложной, что разработчики перестают справляться с поддержкой бизнес-логики в стиле Transaction Script (бизнес-логику становится сложно понимать, из-за этого возникает много багов, которые очень сложно фиксить не порождая новые баги). И в такой ситуации это вполне уместно.

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

            Основная проблема DDD в том, что с ним довольно сложно разобраться, порог входа довольно высокий. Сделать это самостоятельно за несколько дней, по вечерам и выходным - практически невозможно. Лично у меня ушло примерно две недели, с утра до вечера, на чтение книг, статей и просмотр докладов, пока не случился "aha moment". И это только на тактическую часть DDD (потому что будучи архитектом с 30+ годами опыта стратегическую часть DDD я к этому моменту уже давно хорошо понимал и практиковал). Из-за этого порога входа большинство понимает и применяет тактику DDD некорректно (stackoverflow и блоги завалены вопросами по таким кейсам), получая в результате с таким "DDD" ещё больше проблем, чем без него.

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


            1. michael_v89 Автор
              18.03.2024 21:10

              Ну в общем да, вывод из изложенного в статье, можно сформулировать и так, что если вы не понимаете DDD, делайте нечто вроде Transaction Script, это нормально работает. Только даже в этом подходе репозиторий не должен содержать бизнес-логику и заниматься транзакциями. Репозиторий лишь представляет коллекцию сущностей с соответствующими методами.

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


              1. BoShurik
                18.03.2024 21:10
                +1

                чтобы можно было сравнить реализации и оценить возможные проблемы

                Было бы круто увидеть реализацию с Rich Domain Model


                1. michael_v89 Автор
                  18.03.2024 21:10

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


                  1. BoShurik
                    18.03.2024 21:10

                    Без сравнение показать "почему логика в сервисах является более правильным подходом", мне кажется, невозможно. У вас в голове есть некоторая реализация Rich Domain Model к которой вы апеллируете, но у тех кто читает статью, этой реализации нет, поэтому статья кажется неполной


                    1. michael_v89 Автор
                      18.03.2024 21:10

                      Если я напишу неправильно, то сравнивать нет смысла. И в комментариях будут говорить только о том, что сделано неправильно, но никто не захочет сделать правильно. Статья уже и так большая, еще что-то добавлять нецелесообразно. Описаны возможные проблемы и решения, если кто-то решит в своем приложении использовать логику в сущностях, то подумает, как решать указанные проблемы, и возможно решит использовать другой подход.


                1. powerman
                  18.03.2024 21:10

                  На простых небольших примерах DDD никогда не будет выглядеть лучше или проще более прямолинейных подходов (вроде Transaction Script). Наоборот. Именно поэтому никто (включая Эванса и других лидеров DDD) не предлагает применять тактические паттерны DDD в простых микросервисах, где нет реально сложной бизнес-логики.

                  Я лично за всю карьеру сталкивался с таким кейсом всего один раз. Во всех остальных случаях мне удавалось разделить сложную бизнес-логику проекта в целом на кучку относительно несложных Bounded Context (обычно по одному на микросервис), чтобы в каждом отдельно взятом микросервисе тактический DDD не требовался. (Правда, стоит отметить, что конкретно онлайн-магазины я не писал, а обычно именно эта сфера используется в примерах тактического DDD.)


                  1. michael_v89 Автор
                    18.03.2024 21:10

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


                    1. powerman
                      18.03.2024 21:10
                      +1

                      Видите ли… Суть DDD как раз в том, чтобы никаких сложностей при реализации бизнес-логики не возникало в принципе. Вы должны писать в коде (точнее, в слое домена) практически буквально то, что произносит эксперт в предметной области. И если по мере развития проекта с этим начинаются сложности (код начинает сопротивляться дословному выражению требований эксперта), то дизайн нужно переделывать пока сложности не уйдут.

                      Эванс про это говорит вполне однозначно: вы не сделаете "правильный" дизайн ни с первого раза ни с любого другого. По мере углубления понимания предметной области или изменения требований "правильными" моделями окажутся не такие, как сейчас. Вот, к примеру, прекрасный доклад, где Эванс рассказывает большую часть времени о том, какой моделью лучше описывать перевозку груза из точки A в точку B: последовательностью моделей Leg (рёбер соединяющих две точки) или Stop (последовательностью точек). И как изменение понимания предметной области качает весы от одного варианта к другому.

                      Конечно, такого рода рефакторинг - дело недешёвое. (И это только одна из причин, почему использовать тактику DDD в простых проектах нет смысла.) Особенно, если делать его нужно каждый раз, когда выражать бизнес-логику в коде становится даже не сложно, а так, немного сложновато. Но именно такие приёмы DDD и позволяют гарантировать, что бизнес-логику в коде выражать всегда будет просто, и в большинстве случаев это будет почти дословно тому, как описывает требования эксперт.

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


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Вы должны писать в коде (точнее, в слое домена) практически буквально то, что произносит эксперт в предметной области.

                        Ну и у меня в примере буквально то, что произносит эксперт в предметной области. Даже более дословно, потому что соответствуют даже названия переменных в описании алгоритма действий. Бизнес-требования используют термины "заказ" и "товар", а не "я" или "этот".

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

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

                        это означает только то, что то, что Вы делаете в коде - это не DDD

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


                      1. powerman
                        18.03.2024 21:10

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

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

                        Ага, опять абстрактные пожелания, что код надо писать без ошибок)

                        Не-не-не. Это совсем другое. Это не про "делай хорошо - будет хорошо". Это про то, что мы гарантированно получаем только то, что у нас на первом месте в приоритетах.

                        Например, если в проекте практиковать TDD, то есть гарантия, что код будет практически полностью покрыт тестами. Качество тестов может быть паршивым, качество кода тоже, в нём могут быть упущенные тестами баги, он может делать вообще не то, что нужно бизнесу - но именно покрытие кода тестами будет гарантированно высоким.

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


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Это совсем другое.

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

                        и готов принести в жертву всё остальное

                        Ну вот я и привел пример, как описывать бизнес-логику максимально близко к словам эксперта и не приносить в жертву все остальное.

                        И это только одна из причин, почему использовать тактику DDD в простых проектах нет смысла
                        А я с таким столкнулся. Всего один раз

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

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


                      1. powerman
                        18.03.2024 21:10

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

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

                        Ну вот я и привел пример, как описывать бизнес-логику максимально близко к словам эксперта и не приносить в жертву все остальное.

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

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

                        Курсы повышения квалификации для английских дантистов. :) Там из-за неудачного стечения обстоятельств не было возможности применить стратегию DDD и в результате вся бизнес-логика проекта оказалась в одном микросервисе. А это и сами дантисты разных профессий/квалификаций, и их персональные планы развития, и требования гос.регулятора, и собственно обучающие материалы/курсы которые они могли проходить прямо у нас, и разные виды других обучающих активностей которые они могли делать не у нас но у нас учитывать, и поставщики обучающих курсов… посыпать всё это разными требованиями, которым надо соответствовать в определённые периоды времени (от гос.регулятора, из собственных планов пользователей, нашего проекта, …), добавить напоминалки/ачивки/… И это я ещё всех подробностей уже не помню, дело было 6 лет назад.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        то ничего более конструктивного чем "вы неправильно делаете TDD" тут не скажешь

                        Ну как это не скажешь. "У вас не покрыты вот эти методы, значит вы писали их без тестов. Сначала пишем тест вот так [пример кода], потом код метода вот так [пример кода]". Так же как в любом учебнике. Тем более что DDD не про последовательность действий, а про финальный результат для заданных требований.

                        Курсы повышения квалификации для английских дантистов

                        Спасибо за информацию. По описанию выглядит похоже на наше реальное приложение для поставщиков.

                        С моей точки зрения логика в сервисах будет нормально работать с любыми требованиями, просто потому что является их формализованной записью, а они описывают взаимодействие сущностей. Никто не мешает поместить какие-то вычисляемые свойства в сущность, просто надо понять, что сущностями можно манипулировать только через сервис, который моделирует какое-то бизнес-действие. Тогда и не будет вопроса "А что если кто-то приведет ее в неконсистентное состояние?". Значит он неправильно реализовал логику своего бизнес-действия, только и всего. А если бизнес-требования неконсистентны, то от подхода это не зависит.


                      1. powerman
                        18.03.2024 21:10
                        +1

                        "У вас не покрыты вот эти методы, значит вы писали их без тестов. Сначала пишем тест вот так [пример кода], потом код метода вот так [пример кода]". Так же как в любом учебнике.

                        Угу. Только вот комментарий - не учебник. Учебников полно, но их в данном случае явно оказалось недостаточно, раз кто-то умудрился "работая по TDD" написать непокрытый тестами код. Так что тут не ещё один учебник нужен, а полноценное индивидуальное обучение.

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

                        Будет. До какого-то предела. А потом случится то, что называется "проблема масштабирования". Big ball of mud тоже нормально пишется до какого-то предела, а потом почему-то ровно те же действия что и раньше по его развитию начинают требовать экспоненциально больше сил и времени. Но разработчик, у которого все Big ball of mud проекты укладывались в пару месяцев, вполне может искренне в это не верить, потому что не сталкивался лично.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Так что тут не ещё один учебник нужен, а полноценное индивидуальное обучение.

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

                        А потом случится то, что называется "проблема масштабирования"

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

                        вполне может искренне в это не верить, потому что не сталкивался лично

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

                        Вот если, как вы говорите, писать часть бизнес-логики в репозиториях, тогда согласен, будет Big ball of mud. Но зачем так делать, если можно не делать.


                      1. powerman
                        18.03.2024 21:10

                        Лично мне нужен конкретный пример законченного приложения.

                        Поиск по гитхабу "ddd example" не помог?

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

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

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

                        Да, Ваша точка зрения вполне ясна. Но нет, не будет работать. Убедить я Вас в этом, очевидно, не смогу, так что остановимся на том, что наши мнения по этому вопросу расходятся.

                        Но зачем так делать, если можно не делать.

                        Из соображений производительности и консистентности. Иначе придётся либо открывать долгие транзакции "на весь use case" либо самостоятельно реализовывать механизм изоляции вместо встроенного в РСУБД (например, как делает UoW на ручном версионировании строк и оптимистических блокировках).


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        Иначе придётся либо открывать долгие транзакции "на весь use case"

                        Почему нельзя их открывать вручную когда нужно?

                        Поиск по гитхабу "ddd example" не помог?

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

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

                        - Обеспечение логики с 2 транзакциями "сохранить - отправить сетевой запрос - сохранить".
                        - Работа с 2 сущностями-агрегатами в одном действии, например установка значений и сохранение Product и Review в одной транзакции при отправке на ревью.
                        - Работа с мьютексами.
                        - Разделение логики на группы, например чтобы логику для админки нельзя было вызвать из пользовательской части.
                        - Валидация входных данных с загрузкой связанной информации - на входе name, description, category_id, надо подгрузить категорию и проверить статус, если она неактивна вернуть ошибку вместе с ошибками для других полей.
                        - Валидация данных сущности, которая требует зависимости, например валидатор штрихкодов.
                        - Использование зависимостей в логике - при выполнении действия отформатировать дату специальным форматтером, конвертировать markdown в html, при загрузке данных товара из CSV скачать изображение по URL и загрузить на файловый сервер с соответствующими записями в таблице file.


                      1. powerman
                        18.03.2024 21:10

                        Почему нельзя их открывать вручную когда нужно?

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

                        • транзакция тупо вокруг каждого use case целиком (обычно в DDD)

                        • транзакция тупо вокруг каждого метода глобального репозитория (обычно в Transaction Script)

                        • транзакция исключительно вокруг метода сохранения изменений накопленных в UnitOfWork (обычно в DDD)

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

                        Там нет моего приложения. Мне интересно именно сравнить реализации для одинаковых бизнес-требований.

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

                        • Обеспечение логики с 2 транзакциями "сохранить - отправить сетевой запрос - сохранить".

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

                        • Работа с 2 сущностями-агрегатами в одном действии, например установка значений и сохранение Product и Review в одной транзакции при отправке на ревью.

                        Обычно бизнес допускает в таких ситуациях eventual consistency. В этом случае первый use case добавляет/изменяет агрегат Review плюс отправляет доменное событие, а второй use case (обработчик этого события) отражает результат добавления/изменения ревью на агрегате Product.

                        • Работа с мьютексами.

                        Обычно не требуется. Каждый use case подгружает из БД собственный инстанс агрегата, конфликты одновременного изменения одного агрегата разными use case разрешаются не через мьютексы, а через транзакцию в БД.

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

                        Это вопрос авторизации, и он не очень простой. Дело в том, что DDD очень не нравится идея замусоривать методы моделей логикой авторизации (потому что она нужна буквально во всех и это затрудняет понимание их основной бизнес-логики). Так что обычно (если возможно) авторизация делается где-то на предыдущих этапах: сетевой файрвол, edge proxy, слой приложения (use case). Сами модели остаются не защищены, так что мешать таким вызовам именно в слое домена - некому.

                        • Валидация входных данных с загрузкой связанной информации - на входе name, description, category_id, надо подгрузить категорию и проверить статус, если она неактивна вернуть ошибку вместе с ошибками для других полей.

                        Если требуется загружать через репозиторий другие сущности (помимо основного агрегата), то это рекомендуется делать либо в доменном сервисе (если заранее известно какие сущности нужно загрузить), либо (в исключительных случаях) если какая сущность нужна станет ясно уже в середине выполнения метода агрегата, то дать агрегату доступ к репозиторию (в идеале - только к методам репозитория которые не изменяют данные в БД). Если загрузка выполнялась в доменном сервисе, то это не означает, что и сама логика валидации должна быть в нём - он вполне может передать загруженные объекты параметрами в метод агрегата, чтобы агрегат валидацию делал сам.

                        • Валидация данных сущности, которая требует зависимости, например валидатор штрихкодов.

                        Ровно так же, как в предыдущем пункте, только вместо реализации репозитория (из слоя инфраструктуры, доступную в слое домена через инверсию зависимостей) используется такая же реализация клиента к внешнему API/библиотеке.

                        • Использование зависимостей в логике - при выполнении действия отформатировать дату специальным форматтером, конвертировать markdown в html, при загрузке данных товара из CSV скачать изображение по URL и загрузить на файловый сервер с соответствующими записями в таблице file.

                        В общем случае всё это - не задачи для слоя домена. Бывают исключения, конечно (когда форматирование markdown в html это основной/ключевой функционал нашего проекта), но в большинстве проектов это задачи для сервисов в слое инфраструктуры (доступных в домене через интерфейсы/инверсию зависимостей) или даже вообще UI.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        то Вы и напишите аналоги задач из таких репо в собственном стиле

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

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

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

                        Обычно бизнес допускает в таких ситуациях eventual consistency.

                        Ну то есть нужные бизнес-требования реализовать нельзя.

                        Обычно не требуется.

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

                        конфликты одновременного изменения одного агрегата разными use case разрешаются не через мьютексы, а через транзакцию в БД.

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

                        Это вопрос авторизации, и он не очень простой.

                        Нет, права доступа это другой вопрос. Тут я говорю только про то, чтобы все не было в куче (в одном классе) и чтобы это нельзя было вызвать случайно или по незнанию. Зачем нам логика админки в коде пользовательской части?
                        Кстати да, проверка, что пользователь может редактировать этот товар, это тоже интересный вопрос.

                        Если требуется загружать через репозиторий другие сущности

                        Тут я не понял, как это должно выглядеть, и как возвращать список ошибок на фронтенд.

                        используется такая же реализация клиента к внешнему API/библиотеке

                        То есть пробрасываем техническую зависимость в сущность через аргументы метода? Она становится подозрительно похожа на сервис.

                        В общем случае всё это - не задачи для слоя домена.

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

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


                      1. powerman
                        18.03.2024 21:10

                        В бизнес-требованиях никаких событий нет, там четко сказано "сохранить это, отправить это".

                        А вот здесь DDD, на мой взгляд, использует не совсем корректную подмену понятий: "делаем как произносит словами бизнес" подменяет на "учим бизнес словами произносить немного иначе". Суть идеи в том, что концепцию доменных событий до бизнеса донести несложно (в отличие от разных технических/программерских/инфраструктурных вещей), плюс бизнес действительно нередко подразумевает наличие таких событий даже если не говорит про них явно словами. В результате мы сначала учим бизнес "говорить правильно" :) а потом получаем возможность отразить в коде это дословно. С точки зрения DDD это норм, потому что эксперт предметной области всё ещё в состоянии понимать наш способ описания бизнес-логики, в состоянии сам думать этим способом, и в состоянии корректно описывать требования этим способом.

                        Ну то есть нужные бизнес-требования реализовать нельзя.

                        Как раз бизнес-требования - можно. А вот искусственно добавленные разработчиками требования немедленной консистентности (в которой бизнес не нуждается и которые не требовал) - нет.

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

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

                        Что до сложностей, то чем искать способы реализовать что-то сложное лучше искать способы получить нужный результат избежав реализации чего-то сложного. Хороший пример в контексте DDD - идея дробления проекта на несколько Bounded Context, что позволяет сильно снизить сложность реализации каждого из них.

                        При отправке на ревью мьютекс это не замена транзакции, он сохраняется в течение 2 транзакций и сетевого вызова.

                        Хм. А что случится, если сервис в середине этого всего упадёт?

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

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

                        Зачем нам логика админки в коде пользовательской части?

                        Затем, что это - не логика админки! Это - просто штатная операция над товаром, которая требуется бизнес-логике. Кто имеет право её выполнять - в абсолютном большинстве случаев не важно для слоя домена.

                        Думайте про это так: все методы домена гарантируют целостность модели, но они не гарантируют что их вообще кто-то когда-нибудь вызовет (или что вызвавшему метод это было разрешено сделать), что любой товар рано или поздно перейдёт в какое-то состояние, и т.д. и т.п. Методы описывают что с товаром может происходить, и не более того. Если смотреть на приложение в целом - этого недостаточно чтобы понять корректно ли оно реализовано, но если смотреть отдельно на бизнес-инварианты данной модели то этого вполне достаточно для оценки корректности реализации данной модели.

                        Тут я не понял, как это должно выглядеть, и как возвращать список ошибок на фронтенд.

                        Примерно так:

                        • Метод use case в слое приложения:

                          • открыть транзакцию

                          • вызвать метод доменного сервиса

                          • закрыть транзакцию

                        • Метод доменного сервиса в слое домена:

                          • загрузить из репо агрегат

                          • загрузить из репо вспомогательные сущности

                          • вызвать метод агрегата, передав ему вспомогательные сущности параметрами

                          • сохранить агрегат

                        • Метод агрегата:

                          • выполнить валидации, используя в т.ч. переданные параметрами дополнительные сущности

                          • если валидации провалились, то вернуть группу соответствующих доменных ошибок

                        В результате возвращённая методом агрегата группа доменных ошибок через доменный сервис будет передана в use case и из него дальше в сторону UI, возможно где-то по дороге (напр. в слое адаптеров, если используется чистая архитектура) преобразовавшись из доменных ошибок во что-то понятное на уровне API между бэком и UI.

                        То есть пробрасываем техническую зависимость в сущность через аргументы метода? Она становится подозрительно похожа на сервис.

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

                        Это бизнес-требования к работе с товарами, значит это часть домена.

                        Это не так.

                        Если бизнес требует, чтобы конкретный статус товара выводился "красненьким", из этого не следует, что данному функционалу место в инвариантах модели в слое домена, а не в UI.

                        Для модели url картинки, сама картинка в виде набора байт, и путь к файлу с картинкой на файл-сервере - вполне могут быть идентичными способами представления картинки, и какой из них использовать в модели мы можем выбрать сами, а для бизнеса все эти 3 варианта - это просто "картинка".

                        Всё, что относится к особенностям представления данных обычно должно быть в UI, а не в слое домена. Если, конечно, представление данных не является основной задачей нашего приложения (ну т.е. мы не реализуем браузер или просмотрщик pdf).

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


                      1. mike_shapovalov
                        18.03.2024 21:10

                        если валидации провалились, то вернуть группу соответствующих доменных ошибок

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


                      1. powerman
                        18.03.2024 21:10

                        Если мы будем делать проверки бизнес правил последовательно, то код в слое домена будет проще, но ошибку он будет возвращать одну.

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

                        Кроме этого, мне кажется, что то каким образом ошибки будут отображаться это скорее требование слоя UI.

                        Всё верно. Но UI нужно иметь, что отображать. Если UI имеет только первую ошибку, то отобразить все UI не сможет. А вот если UI получит все, то отобразить можно будет что и как угодно - одну, все, первые 3, сразу, по одной, etc. Так что если есть возможность вернуть с бэка сразу все - лучше так и сделать.

                        проверки легко можно продублировать в слое UI

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

                        мне кажется что лучше сделать в агрегате публичный метод который проверяет бизнес правило и возвращает boolean, который может дёрнуть UI, чем усложнять логику агрегата группировкой ошибок.

                        Прямо скажем, не самая лучшая идея:

                        1. Это будет медленно в UI (надо дёрнуть кучу API перед отправкой основного запроса).

                        2. Это будет громоздко на уровне API. Валидаций много - это будет раздувать API в целом в разы (условно, имея 5-10 валидаций на агрегат мы вместо в среднем 5 методов API на агрегат получим 10-15). Валидации могут часто меняться - это будет требовать много (причём вполне возможно несовместимых) изменений API.

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

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

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

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

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


                      1. mike_shapovalov
                        18.03.2024 21:10

                        1. Это будет медленно в UI (надо дёрнуть кучу API перед отправкой основного запроса).

                        Вы меня немного не правильно поняли, я имел ввиду слой UI бэкенд приложения, а не фронтенд приложение.

                        Просто найдите или напишите сами библиотечку для валидаций, которая будет уметь возвращать список ошибок.

                        Но ведь клиентом метода агрегата может быть не только слой UI, а например какой -то сабскрайбер, который каким-то образом может реагировать на ошибку которую возвращает метод агрегата. И код этого клиента будет намного проще если агрегат будет возвращать одну ошибку, а не целый набор. Например в случае использования исключений это будет простой try catch без необходимости итерировать по коллекции вложенных ошибок.

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


                      1. powerman
                        18.03.2024 21:10

                        я имел ввиду слой UI бэкенд приложения

                        О чём речь? Можно пример?

                        И код этого клиента будет намного проще если агрегат будет возвращать одну ошибку, а не целый набор.

                        Для этого есть стандартный подход: в ответе API в поле "error" возвращается одна ошибка (обычно первая или последняя) для простых клиентов, а в поле "details" дополнительно прилагается полный список ошибок для более продвинутых клиентов.


                      1. michael_v89 Автор
                        18.03.2024 21:10

                        А вот искусственно добавленные разработчиками требования немедленной консистентности (в которой бизнес не нуждается и которые не требовал)

                        А вот как раз нет, именно требовал) Просто неявно. Бизнес не знает про транзакции и последовательное выполнение машинных команд. Поэтому когда он говорит "Сохранить", он подразумевает сохранить все сразу, как одну операцию. Он не рассматривает случай, когда часть данных сохранилась, а часть нет, не имеет бизнес-требований к нему.

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

                        Я же написал, блокировка товара от изменений на время всего действия "отправка на ревью", включая проверки, 2 транзакции и сетевой вызов. Более подробно в статье в пункте "История про локи".

                        А что случится, если сервис в середине этого всего упадёт?

                        База освободит мьютекс при закрытии соединения.

                        Это должно быть штатной ситуацией

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

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

                        Да нет, целостность такая же, как и в случае креша с 2 транзакциями без мьютекса. Мьютекс просто тормозит параллельный процесс.

                        Кто имеет право её выполнять - в абсолютном большинстве случаев не важно для слоя домена.

                        Зато важно для программистов, которые поддерживают приложение. Когда все в куче, этим сложно управлять. Это тот же принцип, как и рекомендация делать интерфейсы как можно меньше.

                        Думайте про это так

                        Мне не надо понимать теоретическую идею, которая за этим стоит, мне надо упростить практическую поддержку приложения.
                        В контексте админки обычно вообще можно приводить сущность в любое состояние без проверок консистентности, она часто используется для коррекции ошибок. Соответственно, в сущности будут такие методы.

                        вызвать метод агрегата, передав ему вспомогательные сущности параметрами, сохранить агрегат
                        Метод агрегата: выполнить валидации

                        Что если нам надо только провалидировать возможность действия, без выполнения действия и сохранения? Например, чтобы сделать disabled кнопку отправки на ревью в интерфейсе?
                        Тут еще много вопросов есть, но не буду задавать.

                        "красненьким", из этого не следует, что данному функционалу место в инвариантах модели в слое домена

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

                        Всё, что относится к особенностям представления данных

                        Я говорю не про особенности представления данных, а про бизнес-требования к обработке и валидации данных. Требования к изображению ничем не отличаются от требований к описанию. Просто изображение требует других методов работы с данными, для чего нужны зависимости.
                        Если описание короткое (маленькое), надо вернуть ошибку валидации. Если изображение не скачивается, надо тоже вернуть ошибку валидации. Если скачивается, но его размеры не соответствуют бизнес-требованиям (маленькие), то тоже вернуть ошибку валидации. И при редактировании данных через интерфейс критерии такие же, только файлы загружаются стандартным способом. Это все часть бизнес-действия "Загрузка данных множества товаров из CSV", и нигде кроме домена она быть не может.


  1. bloomdido
    18.03.2024 21:10

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

    https://stackoverflow.com/a/10013528


    1. michael_v89 Автор
      18.03.2024 21:10

      Ну вот напишете приложение полностью, тогда и сравним)
      "Без логики в сервисах" не является самоцелью, любой подход имеет цель уменьшить какие-то сложности. Если он уменьшает сложности в одной части приложения, но добавляет больше сложностей в другой, то зачем его использовать. Вы зачем-то советуете способы, как убрать логику из сервисов, когда статья о том, почему так делать не надо.