
Недавно наша команда столкнулась с новым проектом — крупной backend-системой, которую руководство решило реализовать в формате монорепозитория. Масштаб бизнес-логики оказался огромным, и быстро стало понятно, что без четкой архитектурной дисциплины невозможно поддерживать читаемость, изолировать бизнес-логику и эффективно управлять сложностью. Поэтому мы выбрали подход Domain-Driven Design (DDD), при котором домен описывает бизнес-правила, а оркестратор и инфраструктура вынесены в отдельные слои. Меня зовут Рамиль Куватов, я разработчик в VK Tech, и эта статья — попытка описать и систематизировать принципы, которые помогают нам сохранять архитектуру чистой, а систему — устойчивой к изменениям.
Кратко о DDD
DDD — это подход к проектированию сложных систем, направленный на четкое выражение и изоляцию бизнес-логики от технических деталей. Традиционно он включает два уровня:
Стратегический уровень — определяет границы системы (bounded contexts), формирует единый язык (Ubiquitous Language) для взаимодействия с бизнес-экспертами и между командами, а также управляет отношениями между контекстами (context mapping) и антимоделированием.
Тактический уровень — описывает шаблоны реализации внутри одного bounded context: сущности (Entities), агрегаты (Aggregates), объекты-значения (Value Objects) и доменные сервисы (Domain Services).
В этой статье мы не углубляемся в стратегические аспекты и сразу переходим к тактическому уровню.
Основные принципы
Домен
Вся бизнес-логика сосредоточена в доменном слое (в сущностях, агрегатах и Value Objects). Именно здесь определяются инварианты, допустимые переходы состояний и валидные действия. Изменение состояния и проверка инвариантов должны происходить только через методы сущности, которые явно выражают бизнес-смысл, например Complete(), Cancel(), ChangeOwner() и т. п.
Такой подход делает логику прозрачной, повторно используемой и устойчивой к ошибкам.
Чистые границы
Доменные модели не зависят от инфраструктуры (ни от баз данных, ни от транспорта, ни от внешних DTO), и это позволяет переиспользовать бизнес-логику независимо от того, какие технологии используются в инфраструктурном слое. Репозитории, адаптеры, транспорт, логирование, мониторинг и прочая инфраструктура выносятся за пределы домена и используются только в application-слое.
Сервисы-оркестраторы
Это тонкий слой, который координирует действия между доменными объектами и инфраструктурой. Он сам не содержит бизнес-логики, но управляет вызовами доменных методов, сбором данных и сохранением результатов. Он также единственный слой, который может общаться с внешними системами и взаимодействовать с репозиториями, отправлять события, логировать и т. п.
Что такое доменная модель
Любая структура, которая выражает бизнес-правила и поведение, характерные для предметной области.
В широком смысле слова это не один конкретный тип, а совокупность:
Entity;
Aggregate;
Value Object (VO);
Domain Service.
Entity (сущность)
Объект предметной области с идентичностью (ID) и жизненным циклом. Он может иметь состояние, бизнес-методы и участвовать в агрегатах.
Признаки:
имеет ID, по которому определяется уникальность;
может иметь изменяемое состояние;
инкапсулирует инварианты;
может быть частью агрегата или его корнем.
Value object (VO)
Это объект, который не имеет идентичности и определяется исключительно своими значениями. Он иммутабельный и используется для представления концепций, не являясь сущностью.
Признаки:
Нет ID. Объекты считаются равными, если у них равны значения.
Иммутабельность. После создания не изменяется (в концепции языка без экспорта полей).
Инкапсулирует валидацию. Проверяет корректность значений на этапе создания.
Не хранится отдельно. Не имеет своих таблиц/репозиториев.
Агрегат
Это кластер сущностей и VO, которые логически связаны и управляются как единое целое. Агрегат определяет границы консистентности и входную точку для операций над связанными объектами.
Признаки:
Содержит доменную сущность. Только через нее осуществляется доступ к другим данным.
Гарантия инвариантов. Любая операция сохраняет внутреннюю согласованность.
Граница транзакции. Всё внутри агрегата изменяется в рамках одной транзакции.
Не раскрывает вложенные сущности наружу напрямую. Все действия идут через aggregate root.
Domain Service
Это компонент доменного слоя, который инкапсулирует бизнес-логику, не принадлежащую конкретной сущности или агрегату, но всё еще являющуюся частью предметной области.
Используем когда логика:
неестественно ложится на одну конкретную сущность;
требует координации нескольких объектов;
при этом остается внутри одного домена.
Признаки:
содержит бизнес-логику;
не имеет собственного состояния;
не является сущностью или VO;
работает с несколькими моделями;
находится в доменном слое;
не зависит от инфраструктуры.
Вспомогательные элементы
View/Read Model
Это проекция доменной модели, предназначенная только для чтения, часто агрегированная под конкретный use-case.
Признаки:
Не является частью домена.
Не содержит бизнес-логики. Если у View появляется поведение, то размывается граница между Domain Model и View Model, что нарушает SRP. Исключение: методы которые не меняют состояние и не выполняют бизнес-логику, например String(), Format().
Используется для отображения, отчетов, API-ответов и т. п.
Data Transfer Object (DTO)
Временная структура, использующая для передачи данных между слоями, например между транспортом и сервисом.
Признаки:
Не имеет поведения и бизнес-логики.
Используется в транспортном слое (gRPC, HTTP), маппится к/из Entity/VO через конверторы.
Может быть двунаправленным (RequestDTO ↔ ResponseDTO).
Зачем это нужно
Value Objects (VO) позволяют повторно использовать бизнес-логику, не дублировать валидации и сохранять чистоту кода.
Агрегаты помогают сгруппировать связанную логику и контролировать состояние сложных объектов через единый вход (aggregate root).
View/Read Model оптимизируют чтение данных, разгружают доменную модель и позволяют безопасно отображать агрегированные представления.
DTO обеспечивают слабую связанность между слоями и серверами, позволяют изолировать изменения и формировать API-контракты.
Структура
internal/
└── app/
├── domain/ // только доменная логика, без внешних зависимостей
│ └── tasks/
│ ├── entity.go // сущности
│ └── value_objects.go // value object'ы
│
├── application/
│ └── taskservice/
│ └── service.go // оркестратор, работа с репозиториями
│
├── infrastructure/
│ ├── db/
│ │ └── task_repo.go // реализация репозитория
│ └── transport/
│ └── http/
│ └── handlers.go // HTTP-обработчики
Примеры
Сущность и ее инварианты:
// domain/tasks/entity.go
type Task struct {
ID string
Status Status
OwnerID string
}
func (t Task) Complete() (Task, error) {
if t.Status != StatusInProgress {
return Task{}, ErrInvalidStatusTransition
}
return t.asCompleted(), nil
}
func (t Task) asCompleted() Task {
return Task{
ID: t.ID,
Status: StatusCompleted,
OwnerID: t.OwnerID,
}
}
2. Orchestration:
// application/taskservice/service.go
func (s *Service) CompleteTask(ctx context.Context, id string) error {
task, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
newTask, err := task.Complete()
if err != nil {
return err
}
return s.repo.Save(ctx, newTask)
}
3. Value Object:
// Value Object: Email
type Email struct {
value string
}
func NewEmail(s string) (Email, error) {
if !strings.Contains(s, "@") {
return Email{}, errors.New("invalid email")
}
return Email{value: s}, nil
}
4. Entity:
// Entity: User
type User struct {
id uuid.UUID
email Email
}
// с использованием иммутабельности
func (u User) WithEmail (newEmail Email) User {
return User{
id: u.id,
email: newEmail,
}
}
Тестирование
Разделение ответственности между слоями упрощает написание и поддержку тестов:
сущности и сервисы из domain-слоя (юнит-тесты);
сервисы application (тестируются с моками).
Когда использовать application-сервис, а когда доменный метод?
Если операция относится к одной сущности и не требует взаимодействия с другими, то это метод сущности.
Если требуется координация между несколькими доменами/инфрой, это задача сервисного слоя.
Гибкость и адаптация DDD
В проектах, где используется монорепозиторий (да и не только), в рамках которого сервисы реализуются по правилам DDD, архитектура допускает практичные отступления и адаптации, позволяющие сохранить баланс между чистотой DDD и удобством разработки.
Заключение
Доменно-ориентированный подход позволяет строго отделить бизнес-логику от технических деталей, облегчает сопровождение и развитие системы. Соблюдение описанных правил обеспечивает чистую архитектуру, масштабируемость и читаемость кода.
DDD — это архитектурный стиль, который должен адаптироваться под масштаб проекта, команду и технические ограничения.
Комментарии (11)
olku
04.07.2025 13:47В последнее время наблюдаю на Хабре пересказ книжки Эванса. VO может иметь естественный уникальный идентификатор. Более того, очень полезно его выявить - это делает код проще и меньше. Не заметил в домене интерфейса инфраструктуры, если такое вообще применимо к go. Хороший пример с VO для е-мейла. Валидация не годится, но идея такая - если есть примитив который надо валидировать - это пациент VO.
vasyakolobok77
04.07.2025 13:47VO может иметь естественный уникальный идентификатор
Идентичность VO определяется всеми его атрибутами. Безусловно, VO может содержать какой-то атрибут, значения которого уникальны, но это чаще всего ошибка. Не могу придумать валидного примера. Если знаете такой, приведите в пример.
если есть примитив который надо валидировать - это пациент VO
Однозначно. Но в целом VO может и не иметь валидации. В статье приводится пример некоторой сущности "Задачи". Я бы еще выделил как VO идентификатор задачи TaskID и автора OwnerID. Это обезопасит от коллизии идентификаторов, когда вместо ИД задачи / автора пытаются подсунуть черти что.
olku
04.07.2025 13:47Пример дублирования естественного и искусственного идентификатора есть в статье - Email. Мы не знаем причину, возможно, у нас инфраструктура без UUID не работает.
В статье пример с задачами не показывает всю бизнес логику. Например, Rest API который будет искать задачу по идентификатору. Чтобы знания об инфраструктуре не текли в домен, а ввод идентификатора проверялся, разработчик добавит в VO фабрику вроде fromString с валидацией входных данных. Многие объекты реального мира, которые мы отражаем в DDD, имеют естественный уникальный идентификатор.
Xoccta
Хорошая статья, стало яснее, что подразумевается под "application layer" (ранее рассматривал его как аналог DAO). И понравилась рекомендация, "адаптироваться под масштаб проекта".
Был опыт работы с DDD, точнее переписывал написанный по этой архитектуре проект (краткие вводные - работали на аутсорсе, "добро" от руководителя проекта на реализацию DDD было получено, однако после реализации проект был воспринят резко негативно из-за "огромного бойлерплейта"). Было решено разрабатывать "старым добрым, как у нас во всем монорепозитории". Помимо этого аргументом являлся тезис, что мы не можем изменить способ получения данных. Этот тезис я пытался опровергнуть, мотивируя, что мы спокойно можем поменять способ получения данных и вместо обращения к базе данных, можем полученать их по API с 1С. Однако, глава разработки не оценил, в связи с чем архитектура угасла.
PS. DDD разрабатывал мой начальник, не я.
deadlynch
Мне кажется тут есть пара проблем.
Первая проблема, это редко когда есть возможность спроектировать вместе с бизнесом весь домен. А ведь с этого книжка Эванса и начинается. Надо много сессий и времени менеджмента. Часто бизнес и сам не знает точно как и что происходит. А постоянные синки разработки и бизнеса трудно продать бизнесу.
Вторая проблема вытекает из первой. Разработчики пытаются спроектировать домен на урывках информации. Ребята что помоложе пишут код по аналитики "в лоб", скатываясь в процедурное программирование. Кто поопытнее, делает архитектуру более поддерживаемой, часто выбирая DDD или гексагональную. Но поскольку не обладают всей полнотой знаний о домене, делают много догадок. От этого, либо неправильные абстракции, или переусложнение решения. И то и другое потом лечат костылями, а после костылей это поддерживать невозможно.
Идеально не сделать. Надо смотреть по ситуации.
РС Догадываюсь, что "огромный бойлерплейт" это про маппинг, верно?
Xoccta
Все так, команда была молодая (включая тех, кто нанимал), можно сказать, только начинающая, опыт работы с DDD у всех был первый (зачастую как и опыт работы). В связи с этим сам DDD был реализован скорее всего тоже не оптимально. Да, огромный бойлерплейт это тысячу преобразований, конвертаций в DTO и обратно, скорее всего валидацию тоже посчитали "бойлерплейтом". До самой книги не добрался ещё, надо будет прочитать