DDD: Domain-Driven Design на практике
DDD — подход к проектированию сложных систем, в котором код отражает язык бизнеса. Агрегаты, Value Objects, Bounded Contexts — инструменты для борьбы со сложностью в домене, а не в инфраструктуре.
Почему это важно: Когда бизнес-логика сложна (финансы, логистика, медицина), код быстро расходится с реальностью. DDD заставляет команду говорить на одном языке с бизнесом и моделировать домен явно, а не прятать его в if/elsif.
Главная идея
Ubiquitous Language — общий язык разработчиков и бизнеса. Если бизнес говорит «оформить возврат», в коде должен быть метод order.process_refund, а не update(status: 3). Агрегат — кластер объектов, который изменяется как единое целое.
Как это выглядит на практике
- Бизнес: «Клиент может оформить возврат в течение 14 дней, если товар не повреждён»
- Без DDD: if order.created_at > 14.days.ago && item.condition != 'damaged' && order.status == 2 ...
- С DDD: order.eligible_for_refund? инкапсулирует бизнес-правило внутри агрегата
- Бизнес: «Возврат создаёт кредит на счёт клиента»
- DDD: order.process_refund возвращает доменное событие RefundProcessed
- Подписчик на 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 разделяет систему на модули, где каждый термин имеет одно чёткое значение
Термины урока
Связь с работой 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 — три столпа, которые помогают укротить сложность в нетривиальных бизнес-системах.
Комментарии к уроку
Войдите, чтобы оставить комментарий.
Пока нет комментариев — будьте первым.