
Недавно наша команда столкнулась с новым проектом — крупной 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) и через нее осуществляется все взаимодействие с агрегатом извне.
Признаки:
Имеет один aggregate root, который предоставляет доступ ко всем вложенным данным и поведению.
Гарантирует инварианты: все бизнес-правила внутри агрегата соблюдаются при любых операциях.
Граница транзакции: все изменения агрегата происходят в рамках одной транзакции.
Инкапсулирует вложенные сущности: внешнему коду доступен только 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. Агрегат:
// Aggregate root: Task
// Task управляет сущностью User
type Task struct {
ID string
Status Status
owner User
}
func (t Task) ChangeOwnerEmail(email Email) Task {
return Task{
ID: t.ID,
Status: t.Status,
owner: t.owner.WithEmail(email),
}
}
// 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 — это архитектурный стиль, который должен адаптироваться под масштаб проекта, команду и технические ограничения.