Skip to content

Domain-Driven Design

Использовал технологию DDD для реализации доменной backend-логики.

Реализовывал:

  • отдельные слои app/Application, app/Domain, app/Infrastructure;
  • use-case handlers вместо логики в контроллерах;
  • value objects и domain-entities;
  • порты и инфраструктурные адаптеры для БД, очередей и файловых хранилищ;
  • отдельные read/write сценарии для API-операций.

Пример из практики #1

Дневной рацион пользователя

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

  • DailyRationAggregate - Aggregate Root, который представляет рацион пользователя на конкретный день и задаёт границу согласованности для всех изменений внутри этого рациона;
  • MealEntry - Entity внутри агрегата, отдельный приём пищи с собственным состоянием: тип, рецепт, порция и привязка к дате;
  • MealDate - Value Object, который представляет дату рациона как доменное значение, а не просто строку из запроса;
  • MealType - доменный enum, который ограничивает допустимые типы приёмов пищи и убирает "магические строки";
  • Portion - Value Object для размера порции с проверкой допустимого диапазона;
  • NutritionVector - Value Object для набора нутриентов, который используется в расчётах калорий, белков, жиров и углеводов;
  • UserNutritionProfile - Value Object с параметрами пользователя, нужными для расчёта nutrition goal;
  • MealSelectionOptions - Value Object с параметрами и ограничениями для алгоритма подбора блюда.

Описание:

  • рацион на день описан как aggregate;
  • отдельный приём пищи оформлен как entity со своим состоянием;
  • дата, порция и nutrition-данные оформлены как value objects с явными правилами;
  • доменные ограничения и инварианты живут ближе к самой модели, а не в контроллере.

Подбор и пересчёт состава приёмов пищи

Использовал отдельные domain services:

  • MealOptimizer;
  • BreakfastOptimizer;
  • LunchOptimizer;
  • DinnerOptimizer;
  • SnackOptimizer;
  • DailyNutritionGoalCalculator.

Реализовал функциональность:

  • распределение дневной nutrition goal по типам приёмов пищи;
  • подбор базового рецепта и supplement-компонента;
  • расчёт portion-ов;
  • сравнение вариантов по score/error;
  • работа с дефицитами по макроэлементам.

Прикладной алгоритм подбора рецептов для рациона вынесен в доменный слой, а не спрятан в SQL, контроллере или util-классах.

Пример из практики #2

Export jobs для PDF

Данный пример описывает логику вокруг асинхронного экспорта дневного рациона в PDF: создание export job, постановку задачи в очередь, хранение текущего статуса, сохранение результата и последующую выдачу файла пользователю.

ОбъектРоль в DDDДля чего нужен
ExportJobEntityПредставляет export job как отдельную доменную сущность со своим lifecycle: создание, обработка, успешное завершение или ошибка.
ExportJobIdValue ObjectХранит идентификатор export job как отдельное доменное значение, а не как произвольную строку.
ExportJobPayloadValue ObjectСодержит данные, с которыми запускается экспорт, например дату рациона и служебные параметры запроса.
ExportJobStatusValue Object / enumОписывает текущее состояние export job и ограничивает допустимые переходы между статусами.
ExportJobTypeValue Object / enumОграничивает допустимые типы экспорта и убирает "магические строки" из прикладного сценария.

Описание:

  • экспорт оформлен как отдельная доменная сущность, а не как разовая операция внутри контроллера;
  • у export job есть явный lifecycle: pending -> processing -> ready или failed;
  • статус, тип и payload вынесены в отдельные доменные объекты.

Как это выглядит на уровне application-слоя

Контроллеры не принимают прикладные решения напрямую, а собирают command/query и передают их в handler.

Пример связей между обработчиком в контроллере и командами домена:

  • AddRecipeToUserRationCommand -> AddRecipeToUserRationHandler;
  • AdjustMealPortionsCommand -> AdjustMealPortionsHandler;
  • GetUserDailyRationCommand -> GetUserDailyRationHandler;
  • ExportPdfDayRationJobCommand -> ExportPdfDayRationJobHandler;
  • DownloadPdfDayRationCommand -> DownloadPdfDayRationHandler.

HTTP-слой остаётся точкой входа, а use case живёт отдельно и может переиспользоваться вне контроллера.

Ports / Adapters на практике

Прикладной слой работает через порты, а конкретная Laravel-инфраструктура подключается снизу.

Примеры портов:

  • ExportJobRepositoryPort;
  • UserDayRationPdfJobQueuePort;
  • инфраструктурные реализации через Eloquent, очередь, файловое хранилище и PDF builder.

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

Read / Write разделение

Делал разделение на read/write сценарии, например:

  • чтение списков рецептов идёт через ListRecipesQuery;
  • чтение сохранённых рецептов вынесено в отдельный query flow;
  • изменение дневного рациона идёт через command/use-case handlers;
  • экспорт и скачивание результата тоже оформлены как отдельные сценарии.

Отдельно использовал декораторы над query-handler-ами для кэширования read-моделей, например списков рецептов и сохранённых рецептов. Инфраструктурный кэш добавляется вокруг use case, не смешиваясь с доменной логикой.

Сайт обновлен и проверен: