Недавно наша команда столкнулась с новым проектом — крупной backend-системой, которую руководство решило реализовать в формате монорепозитория. Масштаб бизнес-логики оказался огромным, и быстро стало понятно, что без четкой архитектурной дисциплины невозможно поддерживать читаемость, изолировать бизнес-логику и эффективно управлять сложностью. Поэтому мы выбрали подход Domain-Driven Design (DDD), при котором домен описывает бизнес-правила, а оркестратор и инфраструктура вынесены в отдельные слои. Меня зовут Рамиль Куватов, я разработчик в VK Tech, и эта статья — попытка описать и систематизировать принципы, которые помогают нам сохранять архитектуру чистой, а систему — устойчивой к изменениям.

Кратко о DDD

DDD — это подход к проектированию сложных систем, направленный на четкое выражение и изоляцию бизнес-логики от технических деталей. Традиционно он включает два уровня:

  1. Стратегический уровень — определяет границы системы (bounded contexts), формирует единый язык (Ubiquitous Language) для взаимодействия с бизнес-экспертами и между командами, а также управляет отношениями между контекстами (context mapping) и антимоделированием.

  2. Тактический уровень — описывает шаблоны реализации внутри одного 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-обработчики

Примеры

  1. Сущность и ее инварианты:

// 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 — это архитектурный стиль, который должен адаптироваться под масштаб проекта, команду и технические ограничения.

Комментарии (10)


  1. Xoccta
    04.07.2025 13:47

    Хорошая статья, стало яснее, что подразумевается под "application layer" (ранее рассматривал его как аналог DAO). И понравилась рекомендация, "адаптироваться под масштаб проекта".
    Был опыт работы с DDD, точнее переписывал написанный по этой архитектуре проект (краткие вводные - работали на аутсорсе, "добро" от руководителя проекта на реализацию DDD было получено, однако после реализации проект был воспринят резко негативно из-за "огромного бойлерплейта"). Было решено разрабатывать "старым добрым, как у нас во всем монорепозитории". Помимо этого аргументом являлся тезис, что мы не можем изменить способ получения данных. Этот тезис я пытался опровергнуть, мотивируя, что мы спокойно можем поменять способ получения данных и вместо обращения к базе данных, можем полученать их по API с 1С. Однако, глава разработки не оценил, в связи с чем архитектура угасла.
    PS. DDD разрабатывал мой начальник, не я.


    1. deadlynch
      04.07.2025 13:47

      Мне кажется тут есть пара проблем.

      Первая проблема, это редко когда есть возможность спроектировать вместе с бизнесом весь домен. А ведь с этого книжка Эванса и начинается. Надо много сессий и времени менеджмента. Часто бизнес и сам не знает точно как и что происходит. А постоянные синки разработки и бизнеса трудно продать бизнесу.

      Вторая проблема вытекает из первой. Разработчики пытаются спроектировать домен на урывках информации. Ребята что помоложе пишут код по аналитики "в лоб", скатываясь в процедурное программирование. Кто поопытнее, делает архитектуру более поддерживаемой, часто выбирая DDD или гексагональную. Но поскольку не обладают всей полнотой знаний о домене, делают много догадок. От этого, либо неправильные абстракции, или переусложнение решения. И то и другое потом лечат костылями, а после костылей это поддерживать невозможно.

      Идеально не сделать. Надо смотреть по ситуации.

      РС Догадываюсь, что "огромный бойлерплейт" это про маппинг, верно?


      1. Xoccta
        04.07.2025 13:47

        Все так, команда была молодая (включая тех, кто нанимал), можно сказать, только начинающая, опыт работы с DDD у всех был первый (зачастую как и опыт работы). В связи с этим сам DDD был реализован скорее всего тоже не оптимально. Да, огромный бойлерплейт это тысячу преобразований, конвертаций в DTO и обратно, скорее всего валидацию тоже посчитали "бойлерплейтом". До самой книги не добрался ещё, надо будет прочитать


  1. olku
    04.07.2025 13:47

    В последнее время наблюдаю на Хабре пересказ книжки Эванса. VO может иметь естественный уникальный идентификатор. Более того, очень полезно его выявить - это делает код проще и меньше. Не заметил в домене интерфейса инфраструктуры, если такое вообще применимо к go. Хороший пример с VO для е-мейла. Валидация не годится, но идея такая - если есть примитив который надо валидировать - это пациент VO.


    1. vasyakolobok77
      04.07.2025 13:47

      VO может иметь естественный уникальный идентификатор

      Идентичность VO определяется всеми его атрибутами. Безусловно, VO может содержать какой-то атрибут, значения которого уникальны, но это чаще всего ошибка. Не могу придумать валидного примера. Если знаете такой, приведите в пример.

      если есть примитив который надо валидировать - это пациент VO

      Однозначно. Но в целом VO может и не иметь валидации. В статье приводится пример некоторой сущности "Задачи". Я бы еще выделил как VO идентификатор задачи TaskID и автора OwnerID. Это обезопасит от коллизии идентификаторов, когда вместо ИД задачи / автора пытаются подсунуть черти что.


      1. olku
        04.07.2025 13:47

        Пример дублирования естественного и искусственного идентификатора есть в статье - Email. Мы не знаем причину, возможно, у нас инфраструктура без UUID не работает.

        В статье пример с задачами не показывает всю бизнес логику. Например, Rest API который будет искать задачу по идентификатору. Чтобы знания об инфраструктуре не текли в домен, а ввод идентификатора проверялся, разработчик добавит в VO фабрику вроде fromString с валидацией входных данных. Многие объекты реального мира, которые мы отражаем в DDD, имеют естественный уникальный идентификатор.