Комментарии 36
Был легаси условно 1000 строк на которые изначально была потрачено Эн человеко дней. Сколько понадобилось для переписывания/ рефакторинга? Какая доля кода была оставлена ввиду нормального качества? Ещё какие-то метрики...
Если взять легаси в 1000 строк, созданный за 7 человеко-дней, то переход на архитектуру с слоями и DDD-Lite мог занять порядка 10-12 человеко-дней. При этом мы смогли сохранить примерно 50–60% существующего кода, который уже отвечал базовым требованиям качества. Скорость внедрения новых фич увеличилась примерно на 20–25%.
Считали как
Если раньше на новую функциональность уходило бы примерно 5 дней (), а после изменений — 4 дня (
). Тогда получается что:
Учитывая вариации в оценках и специфику задач, мы наблюдали увеличение скорости внедрения новых фич в диапазоне 25–30%. Эти цифры подтверждаются сокращением времени от идеи до выпуска, ну и самое главное, снижением количества ошибок и скорости погружения в проблематику задачи.
Продукт активно развивается?
Если нет, то вложения не окупятся
Спасибо за цифры. Мне всегда их не хватает для принятия решения
Как тренер DDD Lite в Java, рекомендую отказаться от Interface. Он полностью заменяется Application. За NestJS спасибо. Его ругают на Спрингоподобность и ад зависимостей. А как вам?
Насчет Interface, интересное замечание. В нашем случае у нас там сложены все dto для валидации входных данных с внешнего мира, т.е. с rest хендлеров (в nest.js это классы с стандартными декораторами полей из class-validator). Т.к их достаточно много, выглядело удобно выделить в отдельный слой.
А что касается NestJS, да он действительно вдохновлён Spring и использует похожие подходы: DI-контейнер, декораторы, модули. Но это не обязательно минус - для некоторых проектов это даёт хорошую масштабируемость и структурированность. "ад зависимостей" — хаос можно устроить в любом проекте и на любой платформе. В этом мире все стремится к энтропии))
Есть что почитать на эту тему? Не улавливаю профита от задумки.
Как база архитектуры вообще — Роберт Мартин "Чистая архитектура"
А если по DDD то, наверное, Вон Вернон "Domain-Driven Design Distilled" или Влад Хононов "Learning Domain-Driven Design: Aligning Software Architecture and Business Strategy"
Как боритесь с энтропией?
На пример когда много всяких выборок из базы с разными комбинациями join'ов. Если протаскивать их через доменый слой, то это может захламить домен кодом, который фактически не содержит бизнес логики.
Если для сборки доменной сущности нужно много join'ов — это нормально, и это допустимо внутри именно репозитория, потому что задача репозитория — восстановить агрегат из хранилища.
Главное правило: обо всей этой сложности домен знать не должен, а сложность инфраструктуры не должна лезть в домен.
Попробую переформулировать вопрос.
Допустим у нас есть некая сложная сущность с большим набором связанных друг с другом свойств. Восстановление сущности - затратная операция. Сущность используется в большом количестве форм с всевозможными комбинациями свойств этой сущности. При этом сущность не подлежит кешированию. С точки зрения DDD допускается получение части свойств сущности, но в таком случае при попытки оптимизировать их частичное получение из репозитория энтропия комбинаций порождаемая формами приводит к энтропии в репозитории. Хочется что бы ни домен, ни рипозиторий не зависели от форм с разными комбинациями полей и при этом для форм собирались оптимальные сущности без лишних полей.
Попробую привести условный пример на сущности "Товар"
У Товара есть свойства: Срок годности; Дата производства; Дата упаковки.
И Производное свойство: Дата годности = дата производства + срок годности.
Таким образом если запросить у сущности Товар дату годности и дату упаковки, то из репозитория потребуется получить 3 свойства: Срок годности, дату производства и дату упаковки. И комбинаций такого плана может быть очень много.
Как правильно выходить из данной ситуации не нарушая DDD?
Кажется для вашего случая неплохо подойдет CQRS подход. Его суть в том, что все операции условного сервиса делятся на два типа: операции чтения и операции записи.
Операции записи являются "тяжелыми", а именно в ходе таких операций вы поднимаете из БД агрегат, восстанавливаете его состояние, выполняете над ним бизнес логику с учетом инвариантов самого агрегата и сохраняете его обратно в БД.
Операции чтения наоборот (из своего названия) подразумевают, что данные, поднятые из БД не будут мутироваться и над ними не будет производиться никаких бизнес операций, и как следствие здесь вам вообще ненужно поднимать из БД данные в разрезе агрегата (с необходимостью восстановления и контроля его внутреннего состояния). Вы просто можете переджоинить кучу таблиц, взять из них нужные поля и вернуть все это для отображения.
типовая боль таких экспериментов - маппинг фреймворковой модели/энтити на доменный объект и обратно. расскажите/покажите с чем столкнулись в этом аспекте?
Например, валидировать структуру DTO — это задача контроллера (через декораторы class-validator) или все же домена?
техническая валидация)- контроллеру и слою приложения, бизнесовая - домену, тут все довольно просто. довольно сложно разграничить поначалу, но потом въезжаешь.
декораторы для валидации звучит всрато, это у вас в js такое изобрели?
типовая боль таких экспериментов - маппинг
В DDD для этого рекомендуют делать и выносить отдельные мапперы, как мы и сделали в итоге: маппер из схемы БД в домен и обратно, и маппер с dto в доменный объект и обратно
декораторы для валидации звучит всрато, это у вас в js такое изобрели?
Почти весь nest на декораторах держится) https://docs.nestjs.com/techniques/validation
Предлагаю разобрать "стало" более подробно:
// 2. Бизнес-логика: например, автоматически создаем первый урок, если в DTO он есть
Бизнес-логика переместилась с уровня Domain на уровень Application. Тем не менее, я не могу назвать это "однозначно некорректным". Чем больше Bounded Context делится на агрегаты, тем больше связующей логики перемещается на слой Application, т.к. появляется необхоидмость координировать жизненный цикл этих агрегатов.
await
this.lessonRepo.save
(lesson);
Возможное нарушение границ транзакционности: что произойдет, если первый урок будет добавлен в базу, а сохранение курса через await
this.courseRepo.save
(course)
завершится неудачей (недоступность базы, разрывы сети)?
Например, валидировать структуру DTO — это задача контроллера (через декораторы class-validator) или все же домена?
Валидировать структуру DTO должен тот слой, который является его потребителем, то есть Application. Вероятно, у вашего контроллера есть модель, такая как CreateCourseRequestPayload, с маппингом параметров пути, значений из JWT-токена и заголовков. В этом случае валидация выполняется на этом уровне. Далее в контроллере создается CreateCourseDTO и передается в Application-сервис, который уже выполняет валидацию CreateCourseDTO. Некоторые правила валидации будут дублироваться, и это нормально. Если контроллер или система байндинга обнаруживает ошибку валидации, клиенту возвращается 400, 422 и т.д. Однако, если ошибку валидации выбрасывает Application слой, это должно быть вами интерпретировано как 500, так как означает, что вы неправильно используете свой же Application.
Честно говоря, некоторые части микросервиса получились очень простыми, и для них вся эта слоистость выглядит избыточно.
Вы можете разделить Bounded Contexts на Core и Supporting. Скорее всего, в Core будет бизнес-логика, за которую ценят ваш сервис, и она хорошо впишется в DDD. В Supporting попадает все, где такой сложности нет — история отправленных писем, access log, уведомления, preferences пользователя, конфигурации интеграций, готовые модули, уже реализованные в своей парадигме.
... но приводит к дублированию некоторых обёрток для каждого внешнего сервиса.
Кодогенерация — ваш помощник. Генерируйте библиотеку контрактов и затем переиспользуйте ее. Еще лучше вручную поддерживать библиотеку контрактов и на ее основе генерировать код клиента и сервера. В библиотеке контрактов будут все DTO запросы/ответы и команды/запросы + интерфейс IYourService
. Таким образом, не нужно дублировать контракты, можно генерировать их для разных языков из одного источника и поддерживать в актуальном состоянии. Можно использовать что-то независимое от языка, например, OpenAPI, и затем воспользоваться всеми преимуществами кодогенерации из open-source. Это позволит легко перейти на другой транспорт, например, gRPC, при необходимости.
И в общем:
Пересмотрите границы целостности агрегатов. Помните, что чаще всего один Bounded Context можно реализовать через один агрегат, а затем оценить сценарии конкурентности и производительность, чтобы понять, как разделить этот агрегат на более мелкие части и как это повлияет на консистентность системы.
Рассмотрите возможности использования Domain Events/Integration Events и посмотрите в сторону EDA для оркестрации бизнес процессов; применяйте эти подходы только там, где это имеет смысл.
Проанализируйте систему на устойчивость, например, оцените транзакционную целостность и насколько ваша система устойчива к процессам, прервавшимся где-то посередине выполнения. Не обязательно исправлять все такие сценарии, некоторые из них решаются простым повтором операции пользователем позже, но некоторые могут поставить вас в затруднительное положение при их устранении.
Небольшая обратная связь.
Меня вот такие штуки смущают, на самом деле
Важно, что Application-слой зависит только от Domain-слоя (и его интерфейсов) и ничего не знает о реализациях баз данных или HTTP — эти детали придут из вне.
Вот тут не понял. Если у вас этот слой использует различные интерфейсы из инфраструктурного слоя, слоя представления, то получается он зависит не только от слоя домена.
Вот эта вот штука:
Новый подход: Application-слой (CourseService) оркестратор
@Injectable()
export class CourseService {
constructor(
private readonly courseRepo: CourseRepository, // абстракция репозитория
private readonly lessonRepo: VideoLessonRepository,
private readonly organizationAdapter: OrganizationAdapter // абстракция внешнего сервиса
) {}
async createCourse(dto: CreateCourseDto): Promise<CourseDto> {
...
}
}
Ясно указывает, что слой приложения зависит и от слоя представления, и от слоя инфраструктуры. Да, благодаря внедрению зависимостей и абстрактным интерфейсам разорвана связь между реализацией, но сама связь сохранена.
Причем вы даже ниже сами же используете такую же фигуру речи: "Таким образом, когда в Application-слое в конструкторе LessonService мы зависим от VideoLessonRepository (абстракции), NestJS внедрит нам экземпляр VideoLessonRepositoryImpl из инфраструктуры. "
Или вы хотите сказать, что абстрактные классы/интерфейсы слоев представления, инфраструктуры не относятся к этим слоям? Но тогда слой приложения зависит не только от слоя доменов, но еще и от слоя, который обслуживает вот эти интерфейсы.
Благодаря тому, что зависимости направлены только внутрь (инфраструктура -> приложение - > домен), а не произвольно, исчезла проблема циклических импортов. Domain не зависит ни от чего, Application зависит только от Domain, Infrastructure зависит от Domain/Application (реализуя их интерфейсы), Interfaces зависит от Application.
Такая же фигня. Я совершенно запутался кто и от кого зависит. Каким образом инфраструктура зависит от приложения? В вашем примере как раз все наоборот инфраструктура зависит от абстрактного интерфейса инфраструктуры, от которого зависит уровень приложения. При этом инфраструктура так же зависит и от Домена.
У вас как раз и нет зависимости инфраструктуры от приложения, потому что если у вас такая зависимость появится, то как раз и станут возможны циклические зависимости: приложение-инфраструктура-приложение
У вас же насколько я вижу по примерам:
приложение-представление
приложение-домен
приложение-инфраструктура
инфраструктура-домен
Статье поставил палец вверх, потому что хорошая информация к размышлению.
Спасибо за интерес к статье!
Ознакомьтесь пожалуйста с "принципом инверсии зависимостей". Он даст вам понять почему зависимость от интерфейса != зависимости от реализации интерфейса и поможет разобраться в примерах.
В смысле? Я же сам выше написал: "Да, благодаря внедрению зависимостей и абстрактным интерфейсам разорвана связь между реализацией, но сама связь сохранена. " - то что вы не зависите от реализации вовсе не отменяет связь между слоями. У вас все так же слой приложения зависит от инфраструктурного слоя и DTO. И если вы допустите, что инфраструктура зависит от приложения, то что вам мешает при написании реализации через внедрение зависимостей пропихнуть в инфраструктурный экземпляр какой-нибудь из экземпляров приложения, который будет связан с экземпляром инфраструктуры?
Возможно я не понял изначально вопроса, прошу прощения.
Мы убирали связь реализации инфраструктуры от бизнес логики, но не связь как таковую вообще.
"Пропихнуть" экземпляр вместо интерфейса нам ничего не мешает. Так же как нам ничего не мешает вообще не делать интерфейсов, слоев и абстракций. Это все условные правила которых мы придерживаемся что бы добиться "простоты" использования, расширяемости, сопровождения и тд.
Тут уже фундаментальное поле для дискуссий — оправдано ли существование вообще хоть какой либо архитектуры.
Причем тут это? Я конкретно ваш текст анализирую, вашу архитектуру. Могу цитату повторить:
Благодаря тому, что зависимости направлены только внутрь (инфраструктура -> приложение - > домен), а не произвольно, исчезла проблема циклических импортов. Domain не зависит ни от чего, Application зависит только от Domain, Infrastructure зависит от Domain/Application (реализуя их интерфейсы), Interfaces зависит от Application.
У вас другие зависимости. Как минимум в показанных примерах. И плюс если вот это правда: "Infrastructure зависит от Domain/Application" - то еще и циклические зависимости не исключены, потому что в вашем примере Application зависит от Infrastructure
Вот тут не понял. Если у вас этот слой использует различные интерфейсы из инфраструктурного слоя
В инфраструктурном слое нет интерфейсов. Интерфейс репозитория это слой Domain или Application. Сами репозитории лишь реализуют интерфейсы из слоев выше и => зависят от них.
Ясно указывает, что слой приложения зависит и от слоя представления
Прямой связи нет. Все через абстрактные классы.
Да, благодаря внедрению зависимостей и абстрактным интерфейсам разорвана связь между реализацией, но сама связь сохранена.
Разорвана. Не могу понять где вы видите ее сохранение.
Причем вы даже ниже сами же используете такую же фигуру речи: "Таким образом, когда в Application-слое в конструкторе LessonService мы зависим от VideoLessonRepository (абстракции), NestJS внедрит нам экземпляр VideoLessonRepositoryImpl из инфраструктуры. "
Не вижу противоречий. Взяли абстракцию, ничего не знаем о реализации, в нужном месте указали реализацию.
Или вы хотите сказать, что абстрактные классы/интерфейсы слоев представления, инфраструктуры не относятся к этим слоям? Но тогда слой приложения зависит не только от слоя доменов, но еще и от слоя, который обслуживает вот эти интерфейсы.
Именно. "от слоя, который обслуживает вот эти интерфейсы" - т.е. от самих себя? Да, так и есть.
Такая же фигня. Я совершенно запутался кто и от кого зависит. Каким образом инфраструктура зависит от приложения?
Тем что реализует абстракции из него.
В вашем примере как раз все наоборот инфраструктура зависит от абстрактного интерфейса инфраструктуры, от которого зависит уровень приложения. При этом инфраструктура так же зависит и от Домена.
Если как вы посчитали " инфраструктура зависит от абстрактного интерфейса инфраструктуры", то где вы увидели что "При этом инфраструктура так же зависит и от Домена"?
У вас другие зависимости. Как минимум в показанных примерах.
Все еще не могу понять где вы это увидели?
вашем примере Application зависит от Infrastructure
Вы путаете зависимость от абстрактных классов Infrastructure и от самого Infrastructure.
Все таки ознакомьтесь дополнительно с "принципом инверсии зависимостей"

Вы путаете зависимость от абстрактных классов Infrastructure и от самого Infrastructure.
Не, я другое перепутал. Так как вы выделили все интерфейсы/абстрактные классы в один слой меня смущало, что инфраструктура могла что-то брать из приложения. Хотя по определению выше слой приложения это варианты использования. Казалось бы какое дело инфраструктуре до вариантов использования? И я прозевал, что вы оказывается логически разделили домен/приложение так, что бы из инфраструктуры можно было брать только определенные интерфейсы.
Просто я думал, что если есть инфраструктурный интерфейс, то ему по определению не нужен никакой интерфейс или реализация слоя приложения. А тогда что ему делать в приложении или домене?
У меня вот в голове и не помещалось зачем из инфраструктуры зависимости к слою приложения? А оказывается там просто намешано интерфейсов.
Плюс, я так и не понял к чему вы тогда относите контейнер приложения? Ну, т.е. конфигурацию DI?
В целом я понял, что вы хотите сказать.
А что мешает не полностью соблюдать эту чистую архитектуру? Если использовать репозитории там, где и так одна строка вызова это только увеличивает кол-во переходов, файлов, папок и т.д, явно не ускорит внедрение новых фич. Понимаю, когда в методе хочется оставить только шаги одного уровня, цели так сказать, легче читается, карта своеобразная получается.
Как я писал выше
"Пропихнуть" экземпляр вместо интерфейса нам ничего не мешает. Так же как нам ничего не мешает вообще не делать интерфейсов, слоев и абстракций. Это все условные правила которых мы придерживаемся что бы добиться "простоты" использования, расширяемости, сопровождения и тд.
Тут уже фундаментальное поле для дискуссий — оправдано ли существование вообще хоть какой либо архитектуры.
Спасибо за статью, интересная тема.
У меня есть вопросы:
Кто-то уже ранее писал в комментарии, но я не увидел ответа - как в примере "Новый подход: Application-слой" должны быть реализованы абстракции к транзакциям БД?
Так как даже в этом простом примере при создания курса вы показали возможность создания вначале урока и транзакция тут видится обязательной, потому что при ошибке создании курса нужно что-то делать с созданым уроком. Как и куда тут можно вписать транзакцию?Хотелось бы увидеть пример как в вашей концепции будет реализована операция изменения некоторых атрибутов курса, удаления курса с учетом возможного наличия уроков?
При изменениях вероятно нужно вначале прочитать агрегат и зависимые агрегаты из БД.Хотелось бы увидеть пример как в вашей концепции будут реализованы операция выборки данных, подразумевает ли ваше АПИ чтение сущностей вместе с зависимыми другими сущностями?
Что лучше применять в TS для абстракций репозиториев и адаптеров - абстрактные классы или интерфейсы и почему именно их?
Выпускают ли ващи сервисы события об изменениях сущностей, как выпуск событий вписывается в вашу концепцию - где и когда происходит выпуск событий?
Межсервисное взаимодействие у вас по HTTP? Не думали о более надежных способах?
На сколько я знаю для транзакций есть отдельные механизмы в каждой ORM
В нашем примере можно сделать так:const session = await mongoose.startSession();
session.startTransaction();
try {
await Lesson.create([{ name: 'test' }], { session });
await Course.create([{ name: 'test' }], { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
session.endSession();
}В нашем реализации используется mongoDB. lesson это поле документа course, так что при удалении course lesson удалится каскадом
Опять же в нашей реализации с mongo мы можем получить как весь документ course, так и отдельные его поля и под-документы lesson. В других ORM вроде тоже есть механизмы select и lazy select которые тянут либо сущность, либо сущность и ее связи
Если рассматривать чисто как контракт взаимодействия слоев, то конечно лучше интерфейсы. Их и мокать проще для тестов и множественное наследование работает. Но если есть какая либо логика реализацию которой можно вынести, то тут уже помогут только абстрактные классы.
Механизмов выпуска событий пока что не делали
http был выбран для примера. Обычно присутствует весь зоопарк, и graphQL и брокеры и секеты и тд
Валидировать структуру DTO — это задача контроллера (через декораторы class-validator) или все же домена? Где хранить mapping Domain <-> DTO — в application слое или прямо в доменных сущностях метод .toDto()?
Слой работающий с DTO находится выше слоя домена. В общем случае взаимодействие между слоями однонаправленное. Из этого следует: 1) домен ничего не знает о существовании объектов DTO; 2) объекты DTO валидируются в Interfaces слое (нет смысла пробрасывать их дальше по слоям, если они невалидные); 3) mapping Domain <-> DTO находится в application слое.
То что описано в разделе "Новый подход: микросервис с DDD-Lite" этой статьи представляет собой классический вариант многослойной архитектуры. Слой, выше названый Interfaces, обычно называют Presentation layer.
Как эксперимент помог распутать спагетти-код: применяем DDD-Lite на микросервисах