
Ситуация, знакомая многим: разрабатываем сервис, пилим в нём фичи, развиваем продукт… но постепенно всё выходит из под контроля. Кодовая база разрастается, зависимости становятся сложнее. Команда разработчиков тратит больше времени на распутывание существующих проблем, чем на создание новой функциональности.
Хорошая новость: распутать спагетти-код можно по-разному, и иногда срабатывают не самые очевидные способы. В нашем случае помогла комбинация действий: не просто выделение части кода в отдельные микросервисы, но и параллельная реализация архитектурного подхода DDD-Lite (в связке с принципами чистой архитектуры).
О том, как в рамках кейса мы избавились от спагетти-зависимостей, поделили сервис на чёткие слои, упростили поддержку и масштабирование кода, — рассказываем под катом. Плюс делимся рекомендациями: кому и при каких сценариях связка «DDD Lite + микросервисы» может пригодиться.
Привет, Хабр! Я Максим, FullStack-разработчик в Сравни. Наша команда «Образование» развивает в компании направления, связанные с онлайн и офлайн-курсами, платформами для обучения и системами управления образовательным контентом.
Ниже расскажу, как в рамках одной из задач мы поэкспериментировали с архитектурным подходом DDD-Lite. Материал может пригодиться тем, кто задумывается об организации архитектуры своих сервисов, хочет уйти от спагетти-кода и в целом ищет сбалансированный подход: между «чисто и по фэншую» и «чтобы реально работало».
Предыстория и мотивация к эксперименту
Началось всё с того, что наша команда столкнулась с проблемой усложнения кода в сервисе образования (education-service). В нём была подсистема LMS (Learning Management System) — управление курсами и видео-уроками, — которая со временем показала потенциал роста, дальнейшего развития и расширения. В старом сервисе вся логика LMS жила совместно с кодом витрин в разных *.service.ts
, без чёткого разделения на слои и абстракции. Бизнес-логика вперемешку с инфраструктурным кодом породила спагетти-код: методы разрастались, между частями системы появились циклические зависимости, было непонятно, где заканчивается бизнес-логика и начинается работа с базой или внешними API. Поддержка и развитие такого кода замедлялись с каждым новым изменением.
Решение созрело само собой — выделить LMS-подсистему из сервиса образования в отдельный микросервис (мы назвали его education-lms-service
) и заодно пересмотреть архитектурный подход. Мы решили попробовать подход Domain-Driven Design в облегчённом варианте — так называемый DDD-Lite — в связке с принципами чистой архитектуры. Почему Lite? Полноценный DDD часто включает много формальностей, что может быть избыточно для небольшого сервиса. Нам же хотелось взять лучшие практики DDD без сверхсложной бюрократии. По сути, DDD-Lite — это использование ключевых тактических паттернов DDD, но без полной стратегической проработки, если масштаб домена того не требует.
Основные ожидания от нового подхода были следующие:
Избавиться от спагетти-зависимостей. Разделить код на логические слои, чтобы каждая часть системы отвечала за свою задачу, и устранить циклические зависимости.
Отделить бизнес-логику от инфраструктуры. Доменный код (правила управления курсами и уроками) не должен знать, какая база данных или какие внешние сервисы используются.
Упростить поддержку и масштабирование. Структурированный код легче расширять новыми фичами, тестировать, а также понимать новым разработчикам.
Что вообще такое DDD-Lite?
Прежде чем перейти к деталям реализации, давайте сверимся по терминологии. DDD (DomainDriven Design) — предметно-ориентированное проектирование — предлагает строить приложение вокруг доменной модели и бизнес-правил.
Классический DDD предполагает разделение системы на Bounded Contexts, разработку Ubiquitous Language с бизнес-экспертами, и целый набор паттернов (агрегаты, доменные события, factories и т.д.).
Однако не всегда есть возможность и необходимость внедрять полный DDD. Когда говорят про DDD-Lite, обычно имеют в виду более практичный, облегчённый подход: мы берём основные идеи (доменные сущности и Value Objects, слой доменных сервисов, абстракции репозиториев и интерфейсов для внешних интеграций) и внедряем слоистую архитектуру, но без фанатизма.
Проще говоря, DDD-Lite — это DDD-подход «по минимуму», достаточный, чтобы навести порядок в коде, но не перегрузить проект излишней сложностью.
Мы решили сочетать DDD-Lite с принципами Clean Architecture (чистой архитектуры), которая близка по духу Onion Architecture. Это означало разделение проекта на несколько концентрических слоев: Domain, Application, Infrastructure и Interfaces (аналогично слоям Domain, Application, Infrastructure, Presentation в классической чистой архитектуре).
Идея в том, что ядро системы — доменная логика — не зависит от внешних деталей, а все зависимости направлены извне вовнутрь. Внешние слои знают о внутренних интерфейсах, но внутренние не знают о реализации внешних.

Диаграмма классической Onion Architecture.
Доменные сущности в центре (синий), вокруг — интерфейсы сервисов и репозиториев (зелёный, голубой), а снаружи — инфраструктура и UI (фиолетовый).
Зависимости направлены к центру, то есть внешние слои могут зависеть от внутренних, но не наоборот.
Почему именно такой подход? Есть мнение, что подобная архитектура действительно может выглядеть избыточной для маленького приложения, но окупается на долгосрочных системах. В маленьких проектах с парой сущностей вполне можно жить и без всех этих слоёв. Однако наша LMS-подсистема содержала достаточно бизнес-логики и могла расти в будущем, поэтому инвестиции в грамотную архитектуру были оправданы.
Как код сервиса выглядел раньше
Для контраста опишу, как выглядел код до рефакторинга, когда LMS была частью монолита. В NestJS-приложении по умолчанию часто делают слой сервисов, но у нас эти сервисы разрослись и стали выполнять слишком много обязанностей сразу. Например, мог быть класс CourseService
со следующими чертами:
Он напрямую обращается к базе MongoDB через модели Mongoose.
В нем же содержится бизнес-логика обработки данных (например, проверка ограничений, расчёт каких-то значений).
Там же происходят вызовы внешних сервисов: отправка HTTP-запросов на почтовый сервис, нотификации и т.д.
Контроллеры просто вызывали методы сервисов, и те возвращали DTO или документы из базы.
В итоге CourseService, LessonService и пр. стали божественными объектами, знающими обо всем понемногу. Появилась взаимозависимость: например, CourseService
может дёргать методы LessonService
и наоборот, что приводило к циклическим зависимостям или хитрым обходным маневрам при инициализации модулей NestJS. Тестировать бизнес-логику было трудно: приходилось мокать как базу, так и HTTP-вызовы, потому что методы сервисов напрямую дёргают и то и другое.
Вот упрощённый пример старого подхода (псевдокод, приближенный к нашей ситуации):
@Injectable()
export class CourseService {
constructor(
@InjectModel(Course) private courseModel: Model<CourseDocument>, // для образения к БД
private http: HttpService, // для вызова внешних API
private lessonService: LessonService, // прямое обращение к другому сервису
) {}
async createCourse(dto: CreateCourseDto) {
// 1. Создание записи курса в базе (инфраструктура внутри бизнес-метода)
const newCourse = await this.courseModel.create(dto);
// 2. Вызов внешнего сервиса напрямую (например, получение доп данных об организации)
const {data: orgData} = await this.http.post('https://organizations/get', {
id: dto.orgId,
});
// 3. Вызов бизнес-логики другого сервиса напрямую
if (dto.firstLesson) {
await this.lessonService.createLesson({ courseId: newCourse._id, orgName: orgData.name, ... });
}
return newCourse; // возвращается документ из базы (ORM объект)
}
}
Новый подход: микросервис с DDD-Lite
Выделив новый микросервис education-lms-service
, мы решили сразу заложить правильную архитектуру. Проект на NestJS (TypeScript) был структурирован по слоям: domain, application, infrastructure, interfaces.
Каждый слой — это отдельная «часть» кода со своими задачами:
Domain (доменный слой) — здесь наша предметная область LMS. Сущности и объекты-значения (Value Objects), инварианты бизнес-логики, интерфейсы репозиториев и других сервисов, которые нужны домену. Доменные модели представлены обычными классами TypeScript.
Например, у нас появились Course
и Lesson
как классы-сущности с определёнными свойствами и методами. Также мы ввели некоторые Value Object — мелкие классы-значения, которые инкапсулируют логику валидации или формирования значения. Примером Value Object может быть Status
, CourseSlug
, LessonDuration
— они хранят значение и сразу проверяют корректность при создании, обеспечивая, что доменная модель всегда находится в корректном состоянии.
Application (прикладной слой) — здесь реализованы use cases нашего приложения и координация работы домена с внешними частями.
По сути, это слой сервисов приложения: классы, которые выполняют операции, требующие обращения к репозиториям, вызова доменных методов и внешних сервисов через абстракции. Они не содержат детальной бизнес-логики (её мы стараемся держать в домене), но оркестрируют последовательность шагов.
В нашем случае, например, CourseService
в этом слое отвечает за сценарии типа «создать курс», «добавить урок к курсу» и т.п. Он получает на вход DTO из внешнего мира, преобразует их в доменные объекты, вызывает методы доменных сущностей или доменных сервисов, пользуется репозиториями для сохранения/чтения данных и дёргает внешние интеграции через интерфейсы (адаптеры).
Важно, что Application-слой зависит только от Domain-слоя (и его интерфейсов) и ничего не знает о реализациях баз данных или HTTP — эти детали придут из вне.
Infrastructure (инфраструктурный слой) — в этом слое находятся конкретные реализации технических деталей: реализация репозиториев для MongoDB, клиенты для вызова внешних API, отправка уведомлений, работа с брокерами сообщений, и прочее.
Например, мы создали CourseRepositoryImpl
на основе Mongoose, который реализует интерфейс CourseRepository
из домена. Здесь же лежат схемы MongoDB (Mongoose-схемы для коллекций курсов и уроков) — это чисто инфраструктурная деталь, домен о ней не знает.
В инфраструктуре может быть реализован класс MailServiceHttp
, который через HttpService
вызывает сторонний почтовый API, реализуя наш доменный интерфейс MailService
. Все эти классы регистрируются в модуле NestJS через DI.
Interfaces (слой интерфейсов/представления) — самый внешний слой, через который внешние клиенты взаимодействуют с нашим микросервисом. Сюда относятся REST-контроллеры NestJS, GraphQL-резолверы (если бы были) — всё, что принимает запросы «снаружи» и передает их в приложение. Также здесь определены DTO для запросов/ ответов API, ведь DTO — это, по сути, формат данных на границе системы.
В нашем education-lms-service
слой Interfaces содержит контроллеры для курсов и уроков ( CourseController
, LessonController
), которые принимают HTTP-запросы, валидируют входящие DTO и вызывают методы Application-слоя (например, courseService.createCourse(...)
). Контроллеры не содержат бизнес-логики — они только перенаправляют данные дальше. Именно этот слой зависит от NestJS фреймворка, от веб-деталей (протокол HTTP, форматы JSON), но это ОК, так как он самый внешний.
Разделив код таким образом, мы получили четкие границы. Например, доменный слой полностью отделен от NestJS и MongoDB — он не импортирует ничего из @nestjs/*
или mongoose
. Домен оперирует чистыми классами и интерфейсами. Благодаря этому, доменную логику теперь можно тестировать без подъёма базы или веб-сервера — достаточно замокать интерфейсы репозитория или почтового сервиса.
Отдельно отмечу внедрение адаптеров для внешних сервисов. Раньше, как видно в примере, мы напрямую вызывали HttpService
внутри метода, чтобы получить данные организации. Теперь мы ввели абстракцию OrganizationAdapter
в домене (интерфейс или абстрактный класс с методом, например, getById(id: string)
).
В инфраструктуре есть класс OrganizationHttpAdapter
, реализующий OrganizationAdapter
— он знает, как вызвать реальный сервис организаций (через HTTP или, может, через очереди — не важно). Application-слой обращается теперь не к HttpService
, а к OrganizationAdapter
(интерфейсу). Тем самым код приложения не зависит от способа получения организаций.
Захотим сменить реализацию на другой механизм — заменим код в адаптере OrganizationAdapter
, и всё, остальной код не поменяется. Такой приём мы использовали и для других интеграций: все внешние вызовы завернуты во внутренние интерфейсы (Ports and Adapters паттерн).
DI и настройка зависимостей
NestJS предоставляет удобный механизм Dependency Injection (пользуясь случаем советую доклад коллеги @HeraldOfDuckness по этой теме), поэтому подключение наших абстракций получилось довольно элегантным. Мы объявляли интерфейсы (точнее, в TypeScript это могли быть abstract class) в доменном слое, а в модуле NestJS привязали через провайдеры.
Например, для связки репозитория уроков:
// domain/videoLesson.repository.ts (доменный интерфейс)
export abstract class VideoLessonRepository {
abstract findById(id: string): Promise<VideoLesson | null>;
abstract save(lesson: VideoLesson): Promise<void>;
// ...другие необходимые методы
}
// infrastructure/videoLesson.repository.impl.ts
@Injectable()
export class VideoLessonRepositoryImpl implements VideoLessonRepository {
constructor(@InjectModel(VideoLesson) private model: Model<VideoLessonDocument>) {}
async findById(id: string) { /* реализация через model.findById */ }
async save(lesson: VideoLesson) { /* model.save или model.create */ }
}
В Nest-модуле (например, LmsModule
) мы регистрируем провайдер:
{
provide: VideoLessonRepository,
useClass: VideoLessonRepositoryImpl
}
Таким образом, когда в Application-слое в конструкторе LessonService
мы зависим от VideoLessonRepository
(абстракции), NestJS внедрит нам экземпляр VideoLessonRepositoryImpl
из инфраструктуры. Слои остаются слабо связаны между собой: domain определяет, что нужно, infrastructure дает конкретную реализацию, связь настроена в IoC-контейнере. Аналогично мы поступили для всех репозиториев и внешних сервисов. Например, CourseRepository
-> CourseRepositoryImpl
, OrganizationAdapter
-> MailServiceAdapter
и т.д.
Сравнение фрагмента кода: было vs стало
Чтобы проиллюстрировать трансформацию, посмотрим на упрощённый сценарий создания нового курса теперь, в новой архитектуре:
// Новый подход: 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> {
// 1. Преобразуем DTO -> доменная сущность (можно через фабрику/конструктор)
const course = Course.create(dto.title, dto.description, dto.ownerId);
// 2. Бизнес-логика: например, автоматически создаем первый урок, если в DTO он есть
if (dto.firstLesson) {
// 3. Вызываем внешнюю интеграцию через интерфейс (получение организации)
const org = await this.organizationAdapter.getById(dto.orgId);
const lesson = VideoLesson.create(dto.firstLesson.title, course.id, orgName: org.name /*...*/);
await this.lessonRepo.save(lesson); // сохранили урок через репозиторий
course.addLesson(lesson.id); // обновили связь в доменной модели курса
}
// 4. Сохраняем курс через репозиторий (инфраструктура остается за кулисами)
await this.courseRepo.save(course);
// 5. Готовим результат для контроллера (можно вернуть доменную сущность или преобразовать в DTO)
return CourseDto.fromCourse(course);
}
}
Видно, что внутри метода createCourse
больше нет прямых обращений ни к базе, ни к HTTP — только к абстракциям репозиториев и сервисов. Бизнес-правила (создать первый урок, получить и добавить имя организации) читаются последовательно, не перемешиваясь с деталями реализации. Контроллер же в Interfaces-слое просто вызывает courseService.createCourse(dto)
и возвращает наружу ответ. Такой код легче поддерживать: например, требование «изменить способ получения организации» сводится к подмене класса OrganizationAdapterImpl
на другой, сам CourseService
менять не придется.
Если завтра решим использовать вместо MongoDB другую базу, пишем новую реализацию CourseRepository
и подключаем её — домен и application-логика снова не изменятся. Также уменьшилась вероятность случайно вызвать что-то лишнее в неправильном месте: например, разработчик просто не сможет напрямую из доменного метода дернуть HTTP, потому что там у него нет доступа к HttpService
.
Первые результаты: плюсы новой архитектуры
После переноса LMS-функционала в отдельный сервис и реализации описанной архитектуры, мы практически сразу ощутили ряд преимуществ:
Читабельность и понятность кода
Теперь, открыв проект, легко навигировать: сущности и правила находятся в domain
, прикладная логика — в application
, работа с БД — в infrastructure
, контроллеры/API — в interfaces
. Новому разработчику проще понять, где искать нужный код. Бизнес-логика выражена явно, без шелухи инфраструктуры вокруг, что делает код самодокументируемым до некоторой степени.
Отсутствие циклических зависимостей
Благодаря тому, что зависимости направлены только внутрь (инфраструктура -> приложение - > домен), а не произвольно, исчезла проблема циклических импортов. Domain не зависит ни от чего, Application зависит только от Domain, Infrastructure зависит от Domain/Application (реализуя их интерфейсы), Interfaces зависит от Application. Такая строгая иерархия избавила от загадочных проблем загрузки модулей NestJS и упрощает рефакторинг — мы точно знаем, что изменение в инфраструктуре не затронет домен.
Простота тестирования
Доменные сервисы и сущности можно тестировать без поднятия MongoDB: достаточно замокать интерфейсы репозиториев. Мы можем писать unit-тесты на методы доменных моделей (например, проверять бизнес-правила курса) вообще без окружения NestJS. Application-слой тоже тестируется с моками репозиториев и сервисов.
Интеграционные тесты можно сосредоточить на инфраструктуре (проверить, что CourseRepositoryImpl
правильно пишет в настоящую базу). Такой разделённый подход к тестам ускоряет разработку и повышает надежность — бизнес-правила проверяются независимо от базы данных.
Гибкость в развитии
Теперь добавление новой фичи в LMS требует меньше усилий по борьбе с существующим кодом. Если нужно добавить новую сущность или новый внешний интеграционный сервис, мы просто добавляем ещё один репозиторий/адаптер, не трогая уже работающие части.
Масштабировать команду тоже проще: разные люди могут параллельно работать над разными слоями или компонентами, практически не наступая друг другу на ноги, потому что контракты между слоями определены интерфейсами. В целом, система стала более расширяемой (extensible) за счёт слабой связанности компонентов.
Контроль доменной логики
С введением Value Objects и доменных методов улучшилась гарантия целостности данных. К примеру, если раньше можно было где-то забыть провалидировать email или длину названия курса, то теперь это делает соответствующий Value Object в момент создания.
Доменные сущности могут сами отвечать за логику (например, Course.addLesson(lessonId)
может проверять, не превышено ли максимальное число уроков для курса и т.п.), вместо того, чтобы размазывать эти проверки по сервисам. Это уменьшает дублирование кода и шанс ошибок.
Сложности и нюансы (что не зашло)
Конечно, не все было идеально гладко — некоторые моменты потребовали привыкания или показались спорными:
Разграничение ответственности не всегда очевидно с первого раза
Несмотря на наличие явных слоёв, были сомнения, куда поместить ту или иную логику. Например, валидировать структуру DTO — это задача контроллера (через декораторы class-validator) или все же домена? Где хранить mapping Domain <-> DTO — в application слое или прямо в доменных сущностях метод .toDto()
?
Мы установили для себя правила, но в новых командах может потребоваться время, чтобы договориться об этих границах. Главное — сохранять консистентность: выбрали подход и придерживаемся его. Со временем архитектурный стиль прояснился, и новых вопросов практически не возникало.
2. Избыточность для малого объема логики
Честно говоря, некоторые части микросервиса получились очень простыми, и для них вся эта слоистость выглядит избыточно. Например, один из endpoints почти тривиально берет данные из репозитория и возвращает наружу — по сути, тут доменная логика отсутствует, а мы всё равно пишем три слоя кода. В таких случаях соблазн обойти архитектуру велик.
Впрочем, сегодня у нас почти не осталось совсем «тупых» сценариев — в процессе развития даже простые операции обросли логикой (фильтрации, сортировки, обогащение данных и т.д.), так что слои пригодились.
3. Взаимодействие с другими микросервисами
LMS-сервис не существует в вакууме — он общается с остальными сервисами нашей платформы (например, с основным education-service). Мы реализовали это через тот же подход портов и адаптеров: определили интерфейсы в домене (например, UserProfileService
с нужными методами), а в инфраструктуре сделали HTTP-клиент к нужному API, реализующий этот интерфейс.
Это добавляет надёжности (можно потом заменить вызов на gRPC или ещё что-то, не трогая домен), но приводит к дублированию некоторых обёрток для каждого внешнего сервиса. В принципе, это нормальная практика в DDD (аналог Anti-corruption Layer), просто следует учитывать трудозатраты на поддержку этих интеграционных адаптеров.
Вывод: даже один выделенный сервис может выиграть от грамотной архитектуры
Стоило ли оно того? По прошествии некоторого времени разработки и поддержки education-lms-service
в новом стиле мы пришли к выводу, что да, архитектурные изменения себя оправдали. Код стал более устойчивым к изменениям: мы несколько раз добавляли крупные функции (например, поддержку текстовых уроков) и делали рефакторинг хранения данных, и каждый раз изменения удавалось локализовать в одном-двух местах, почти не затронув остальную систему. Там, где в монолите изменение заняло бы дни с риском что-то поломать, в новом сервисе это делалось за часы и с уверенностью в результате.
Однако нужно понимать контекст: подобный архитектурный подход полезен не всегда и не везде. Если у вас совсем небольшой сервис или простая CRUD-прослойка, введение четырёх слоёв может оказаться избыточным и только усложнит жизнь.
Мой совет — оцените сложность вашей предметной области. Если бизнес-логика проста, может хватить и более простой архитектуры. Но если вы предвидите рост количества функций, усложнение правил, интеграции с множеством внешних систем — закладывать подход лучше с самого начала. Переделывать архитектуру на ходу сложно и дорого, мы это прочувствовали на основном перегруженном сервисе.
Для тех, кто хочет попробовать DDD-lite на практике, вот несколько рекомендаций из нашего опыта:
Начните с разделения на слои и явных интерфейсов
Даже не внедряя всех паттернов DDD, уже полезно разделить проект на domain/application/infrastructure и ввести абстракции для работы с данными и внешними API. Чёткое разграничение «кто за что отвечает» сразу выявит проблемы дизайна и снизит связность кода.
Используйте DI и возможности фреймворка
В NestJS встроен DI, берите от него максимум. Регистрация provide/useClass
для связки интерфейсов с реализациями — мощный инструмент, который упрощает подмену компонентов. Не экономьте на интерфейсах: даже если сейчас реализация одна, выстраивайте систему так, будто их может быть несколько (например, в тестах или будущем).
Не бойтесь дополнительного кода — бойтесь запутанного кода
Да, файлов станет больше. Но лучше иметь больше классов с чёткими задачами, чем пару «громадин», в которых намешано всё. Разделение кода — это инвестиция. Можно облегчить боль шаблонного кода генераторами, фабриками, использовать абстрактные базовые классы для однотипных репозиториев, чтобы снизить дублирование.
DDD-Lite != пренебрежение дизайном
Хотя подход и называется «Lite», относитесь к дизайну серьезно. Продумайте вашу доменную модель: какие сущности и Value Objects нужны, где границы агрегатов. Продолжайте общаться с коллегами и предметными экспертами, чтобы термины в коде отражали реальный бизнес (Ubiquitous Language все-таки полезна!).
Будьте готовы к эволюции. Архитектура — не догма
Возможно, вы начнете с трёх слоев, потом выделите четвёртый, или решите соединить какие-то части. В нашем случае DDD-структура оказалась удачной, и мы не отступали от нее. Но если вдруг оказалось слишком сложно, лучше упростить, чем слепо следовать шаблону. Цель — облегчить поддержку и развитие, а не соответствовать книжному DDD на 100%.
Наш эксперимент с DDD-Lite на микросервисе показал, что даже один выделенный сервис может выиграть от грамотной архитектуры. Первоначальные затраты на разбиение кода окупились улучшением качества и скорости разработки. Кодовая база стала чище и приятнее для работы.
Да, DDD-подход требует дисциплины и понимания, зачем он вводится. Но когда видишь, что благодаря ему исчезает хаос зависимостей и каждый компонент системы «знает свое место» — понимаешь, что двигались в правильном направлении.
Я рекомендую разработчикам не бояться пробовать такие подходы на важных модулях своего приложения и экспериментировать, если есть возможность. Начните с малого, постепенно вводите слои и абстракции, и, вероятно, вам больше не захочется возвращаться к старому спагетти-коду.
Удачи в проектировании!