Продвинутый

DDD: Domain-Driven Design на практике

Урок 4 из 6 в курсе Архитектурные паттерны и подходы к проектированию

Содержание курса (4/6)

DDD: Domain-Driven Design на практике

DDD — подход к проектированию сложных систем, в котором код отражает язык бизнеса. Агрегаты, Value Objects, Bounded Contexts — инструменты для борьбы со сложностью в домене, а не в инфраструктуре.

Почему это важно: Когда бизнес-логика сложна (финансы, логистика, медицина), код быстро расходится с реальностью. DDD заставляет команду говорить на одном языке с бизнесом и моделировать домен явно, а не прятать его в if/elsif.

Главная идея

Ubiquitous Language — общий язык разработчиков и бизнеса. Если бизнес говорит «оформить возврат», в коде должен быть метод order.process_refund, а не update(status: 3). Агрегат — кластер объектов, который изменяется как единое целое.

Как это выглядит на практике

  1. Бизнес: «Клиент может оформить возврат в течение 14 дней, если товар не повреждён»
  2. Без DDD: if order.created_at > 14.days.ago && item.condition != 'damaged' && order.status == 2 ...
  3. С DDD: order.eligible_for_refund? инкапсулирует бизнес-правило внутри агрегата
  4. Бизнес: «Возврат создаёт кредит на счёт клиента»
  5. DDD: order.process_refund возвращает доменное событие RefundProcessed
  6. Подписчик на RefundProcessed создаёт CustomerCredit — логика явная и тестируемая

Примеры кода

Value Object — Money

# Value Object: определяется значением, а не идентификатором
# Два Money(100, 'USD') — одинаковы, даже если это разные объекты

class Money
  include Comparable
  attr_reader :amount, :currency

  def initialize(amount, currency = 'RUB')
    raise ArgumentError, 'Amount must be >= 0' if amount.negative?
    @amount = amount.round(2)
    @currency = currency.upcase.freeze
  end

  def +(other)
    assert_same_currency!(other)
    Money.new(@amount + other.amount, @currency)
  end

  def -(other)
    assert_same_currency!(other)
    Money.new(@amount - other.amount, @currency)
  end

  def *(multiplier)
    Money.new(@amount * multiplier, @currency)
  end

  def <=>(other)
    assert_same_currency!(other)
    @amount <=> other.amount
  end

  def zero?
    @amount.zero?
  end

  def to_s
    "#{format('%.2f', @amount)} #{@currency}"
  end

  def ==(other)
    other.is_a?(Money) &&
      @amount == other.amount &&
      @currency == other.currency
  end

  private

  def assert_same_currency!(other)
    raise 'Currency mismatch' unless @currency == other.currency
  end
end

# Использование:
price = Money.new(1500, 'RUB')
tax = price * 0.2          # => 300.00 RUB
total = price + tax        # => 1800.00 RUB
price == Money.new(1500)   # => true (сравнение по значению)

Aggregate — Order с бизнес-правилами

# Агрегат Order — точка входа для всех операций с заказом
# Внешний код не может менять OrderItem напрямую

class Order
  class RefundNotAllowed < StandardError; end

  REFUND_WINDOW = 14 # дней

  attr_reader :id, :customer_id, :items, :status,
              :total, :placed_at

  def initialize(id:, customer_id:, items:, placed_at: Time.current)
    @id = id
    @customer_id = customer_id
    @items = items.freeze
    @status = :pending
    @placed_at = placed_at
    @total = calculate_total
    @domain_events = []
  end

  def place!
    raise 'Заказ пуст' if @items.empty?
    @status = :placed
    @domain_events << OrderPlaced.new(order_id: @id, total: @total)
  end

  def eligible_for_refund?
    @status == :placed &&
      Time.current - @placed_at < REFUND_WINDOW.days &&
      @items.none?(&:damaged?)
  end

  def process_refund!(reason:)
    raise RefundNotAllowed unless eligible_for_refund?
    @status = :refunded
    @domain_events << RefundProcessed.new(
      order_id: @id,
      amount: @total,
      customer_id: @customer_id,
      reason: reason
    )
  end

  def pull_domain_events
    events = @domain_events.dup
    @domain_events.clear
    events
  end

  private

  def calculate_total
    @items.sum(&:subtotal)
  end
end

# Бизнес-правило читается как текст:
# order.eligible_for_refund?
# order.process_refund!(reason: 'Не подошёл размер')

Bounded Context — разделение домена

# Bounded Context: один термин — разное значение в разных контекстах
# "Customer" в разных модулях:

# === Контекст: Продажи (Sales) ===
module Sales
  class Customer
    attr_reader :id, :name, :loyalty_tier, :total_spent

    def vip?
      @loyalty_tier == :gold && @total_spent > Money.new(100_000)
    end

    def discount_rate
      vip? ? 0.15 : 0.0
    end
  end
end

# === Контекст: Доставка (Shipping) ===
module Shipping
  class Customer
    attr_reader :id, :address, :preferred_carrier, :phone

    def deliverable?
      @address.present? && @address.valid?
    end
  end
end

# === Контекст: Поддержка (Support) ===
module Support
  class Customer
    attr_reader :id, :email, :open_tickets_count, :priority

    def escalated?
      @priority == :high || @open_tickets_count > 5
    end
  end
end

# Один и тот же клиент, но каждый контекст видит только
# нужные ему данные и поведение.
# Нет god-object Customer с 50 методами.

Что происходит под капотом

  • Ubiquitous Language: если бизнес говорит «оформить возврат», а в коде update(status: 4) — это red flag
  • Агрегат защищает инварианты: нельзя оформить возврат повреждённого товара, даже напрямую в БД
  • Value Object неизменяем: Money(100, 'RUB') + Money(50, 'RUB') создаёт новый объект Money(150, 'RUB')
  • Domain Event — факт, который произошёл в домене: OrderPlaced, RefundProcessed. Другие контексты реагируют на события
  • Bounded Context — граница, внутри которой термин имеет одно значение. Customer в продажах ≠ Customer в доставке

Типичные ошибки и заблуждения

  • «DDD нужен для всех проектов» — DDD оправдан для сложных доменов. Для CRUD-приложения это overkill
  • «DDD = папки domain/application/infrastructure» — DDD про моделирование бизнеса, а не про структуру файлов
  • «Агрегат = модель ActiveRecord» — агрегат это кластер доменных объектов с бизнес-правилами, AR-модель — маппинг на таблицу

Ключевые выводы

  • Ubiquitous Language: код должен читаться как бизнес-процесс (order.eligible_for_refund?, не order.status == 3)
  • Агрегат = граница транзакции + защита бизнес-инвариантов
  • Bounded Context разделяет систему на модули, где каждый термин имеет одно чёткое значение

Термины урока

{:term=>"Ubiquitous Language", :definition=>"Общий язык разработчиков и бизнеса, отражённый напрямую в коде (имена классов, методов, переменных)"}
{:term=>"Aggregate", :definition=>"Кластер связанных объектов, изменяемый как единое целое. Корень агрегата — единственная точка входа для изменений"}
{:term=>"Bounded Context", :definition=>"Граница, внутри которой модель домена консистентна. Один термин может значить разное в разных контекстах"}

Связь с работой backend-разработчика

DDD — не серебряная пуля, а инструмент для сложных доменов. Начните с Ubiquitous Language: если методы вашей модели названы бизнес-терминами, а не техническими (process_refund вместо update_status), вы уже на полпути к DDD.

Мини-разбор реальной ситуации

Логистическая компания: модель Shipment содержала 2000 строк — статусы, расчёт стоимости, маршрутизация, документация, уведомления. После DDD-рефакторинга: 4 Bounded Contexts (Pricing, Routing, Documentation, Tracking), каждый со своей моделью Shipment (200–300 строк). Команды стали работать параллельно, не конфликтуя. Время добавления нового типа доставки сократилось с 2 недель до 3 дней.

Что запомнить

  • Ubiquitous Language: код = бизнес-язык. order.process_refund, не order.update(status: 4)
  • Агрегат защищает бизнес-правила: вся валидация внутри, внешний код не может обойти
  • Bounded Context: лучше 4 маленьких Customer, чем один god-object на 2000 строк

Итог

DDD — подход к проектированию, при котором код отражает реальный бизнес-домен. Ubiquitous Language, агрегаты и Bounded Contexts — три столпа, которые помогают укротить сложность в нетривиальных бизнес-системах.

Комментарии к уроку

Войдите, чтобы оставить комментарий.

Пока нет комментариев — будьте первым.