Как стать автором
Поиск
Написать публикацию
Обновить
275.23
VK Tech
VK Tech — российский разработчик корпоративного ПО

Domain-Driven Design: чистый подход к проектированию бизнес-логики

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров11K

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

Признаки:

  • Имеет один 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-обработчики

Примеры

  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. Агрегат:

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

Теги:
Хабы:
+48
Комментарии49

Публикации

Информация

Сайт
tech.vk.com
Дата регистрации
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Евгений Левашов