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

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


  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, имеют естественный уникальный идентификатор.


        1. vasyakolobok77
          04.07.2025 13:47

          Как уже помянул ранее, идентичность VO определяется исключительно его атрибутами. Если вы в Email зачем-то добавите поле uuid, то это перестанет быть VO, это будет недо-Entity.

          Многие объекты реального мира... имеют естественный уникальный идентификатор.

          Объекты с естественными идентификаторами становятся Entity. VO по своему смыслу не имеет идентификаторов.


          1. voroninp
            04.07.2025 13:47

            Тут надо понимать, что Identity на уровне домена не есть Identity на уровне БД.

            И иногда действительно может иметь смысл воткнуть PK в VO, но он, очевидно, никак не должен участвовать в equality.


            1. vasyakolobok77
              04.07.2025 13:47

              Скорее всего вы не понимаете смысла VO - VO не хранятся в таблицах БД.

              В таблицах БД хранятся Entity, даже если это неизменные справочные данные.

              VO имеют смысл только на уровне кода, как ограничение некого общего пространства значений. Например, Email - из всего пространства строк вы выделяем те, которые похожи на эл.почту. Или я приводил пример заведения типы TaskID / OwnerID - так же, из всего пространства строк / чисел мы выделяем те, которые будут нести конкретный смысл - ИД задачи или ИД автора. Или хороший пример, Деньги - численное значение кол-ва денег + значение валюты.

              Если вы попытаетесь хранить VO как записи в БД, придумав им фиктивный PK. То вы нарушите сразу несколько ограничений = на уровне кода идентичность будет по атрибутам, а в бд - одновременно по PK и по атрибутам? Кто будет поддерживать эту согласованность?


              1. voroninp
                04.07.2025 13:47

                Ну успешного вам хранения коллекции VO в ентити без использования json columns. Это как минимум.

                Во-вторых, VO не обязан быть минималистичным, как email, он может быть и довольно монструозным. В ряде случаев вам нужно будет обеспечить экзотичные не функциональные требования. Эванс, кстати, разбирает это в своей книге.


                1. voroninp
                  04.07.2025 13:47

                  Вот комментарий Вернона: https://github.com/VaughnVernon/IDDD_Samples/issues/22

                  Вот выдержка из книги Эванса:

                  Creating extra options for performance tuning can be important because VALUE OBJECTS tend to be numerous. The example of the house design software hints at this. If each electrical outlet is a separate VALUE OBJECT, there might be a hundred of them in a single version of a single house plan. But if all outlets are considered interchangeable, we could share just one instance of an outlet and point to it a hundred times (an example of FLYWEIGHT [Gamma et al. 1995]). In large systems, this kind of effect can be multiplied by thousands, and such an optimization can make the difference between a usable system and one that slows to a crawl, choked on millions of redundant objects. This is just one example of an optimization trick that is not available for ENTITIES. The economy of copying versus sharing depends on the implementation environment. Although copies may clog the system with huge numbers of objects, sharing can slow down a distributed system. When a copy is passed between two machines, a single message is sent and the copy lives independently on the receiving machine. But if a single instance is being shared, only a reference is passed, requiring a message back to the object for each interaction. Sharing is best restricted to those cases in which it is most valuable and least troublesome:
                  • When saving space or object count in the database is critical
                  • When communication overhead is low (such as in a centralized server)
                  • When the shared object is strictly immutable

                  Например, в определенных случаях может существовать таблица VO где есть синтетический PK и Unique Index по всем значимым атрибутам. И гонять будут только PK между системами. Важно лишь то, что в контексте бизнес-модели у такого объекта не существует identity. Она здесь вылезает не более в качестве технического решения.


                  1. vasyakolobok77
                    04.07.2025 13:47

                    1. Концепт VO существует уже десятки лет. Как минимум Фаулер упоминает его https://martinfowler.com/eaaCatalog/valueObject.html еще в начале 00-х. Здесь он акцентирует внимание на том, что это небольшие объекты.

                    2. В приведенном вами примере из книги Эванса для больших неизменных структур предлагается использовать паттерн Flyweight / фиктивный ИД. И тут возникает вопрос восприятия и именования вещей - где грань между неизменным Entity (со своим ИД и эквивалентностью по всем атрибутам) и VO (с фиктивным ИД и эквивалентностью по всем атрибутам) ?

                      Подход Фаулера мне ближе, все что небольшое и не требует ввода фиктивных ИД - я зову настоящими VO. А там где требуется вводить фиктивные ИД - для меня уже не является VO.


                    1. voroninp
                      04.07.2025 13:47

                      Критерий Эванс предлагает следующий: continuity у entity и концептуальная неотличимость двух объектов с одинаковыми атрибутами у VO.

                      В большинстве случаев VO действительно легковесные и без лишних извращений. В принципе, VO полезен сам по себе без всякого DDD.

                      Эванс во многом опирается на Фаулера и GoF, но вообще там местами есть тонкие отличия, потому как у Эванса рассуждения больше про предметную модель, нежели про техническую реализацию.