Привет, Хабр! Cегодня я хочу поговорить о самом непонятном и переоцененном термине в мире архитектуры — Domain-Driven Design (DDD). Я объясню его так, чтобы стало понятно даже джуну, и покажу на реальных примерах, чем он отличается от других подходов.

Проблема, с которой сталкивался каждый

Вы когда-нибудь видели такой код?

# order_service.py
class OrderService:
    def create_order(self, user_id, product_ids):
        # 1. Проверяем пользователя
        user = db.query("SELECT * FROM users WHERE id = %s", user_id)
        if not user:
            raise Exception("User not found")
        
        # 2. Проверяем товары
        products = []
        total = 0
        for pid in product_ids:
            product = db.query("SELECT * FROM products WHERE id = %s", pid)
            if not product:
                raise Exception(f"Product {pid} not found")
            if product["stock"] < 1:
                raise Exception(f"Product {pid} out of stock")
            products.append(product)
            total += product["price"]
        
        # 3. Проверяем баланс
        if user["balance"] < total:
            raise Exception("Insufficient funds")
        
        # 4. Создаем заказ
        order_id = db.execute("""
            INSERT INTO orders (user_id, total, status) 
            VALUES (%s, %s, 'pending')
        """, user_id, total)
        
        # 5. Добавляем позиции
        for product in products:
            db.execute("""
                INSERT INTO order_items (order_id, product_id, price)
                VALUES (%s, %s, %s)
            """, order_id, product["id"], product["price"])
            
            # 6. Обновляем склад
            db.execute("""
                UPDATE products SET stock = stock - 1 
                WHERE id = %s
            """, product["id"])
        
        # 7. Списываем деньги
        db.execute("""
            UPDATE users SET balance = balance - %s 
            WHERE id = %s
        """, total, user_id)
        
        return {"order_id": order_id}

Что не так с этим кодом? Он работает. Но он:

  1. Знает всё о базе данных

  2. Смешивает бизнес-правила с SQL

  3. Непонятно, что такое "заказ" в терминах бизнеса

  4. При изменении правил нужно менять всё

Это типичный Data-Driven Design — мы проектируем от таблиц.

DDD: переворачиваем с ног на голову

DDD предлагает начать не с таблиц, а с бизнеса. Вот как выглядит тот же процесс в DDD:

# domain/order.py
class Order:
    def __init__(self, order_id: OrderId, customer: Customer, items: list[OrderItem]):
        self.order_id = order_id
        self.customer = customer
        self.items = items
        self.status = OrderStatus.PENDING
        self.total = sum(item.price for item in items)
        
    def place(self):
        # Бизнес-правило: нельзя создать пустой заказ
        if not self.items:
            raise DomainError("Order must have at least one item")
        
        # Бизнес-правило: проверка лимита покупателя
        if not self.customer.can_purchase(self.total):
            raise DomainError("Customer purchase limit exceeded")
        
        # Бизнес-правило: проверка доступности товаров
        for item in self.items:
            if not item.product.is_available():
                raise DomainError(f"Product {item.product.name} is not available")
        
        self.status = OrderStatus.PLACED
        self.place_time = datetime.now()
        
        # Порождение domain event
        self.add_event(OrderPlacedEvent(
            order_id=self.order_id,
            customer_id=self.customer.id,
            total=self.total
        ))

Видите разницу? Этот код не знает о:

  • Базе данных

  • HTTP

  • Внешних API

  • Фреймворках

Он знает только бизнес-правила.

Ключевые строительные блоки DDD

1. Домен (Domain) — это не база данных

Это вся предметная область вашего приложения. Для Uber — это поездки, цены, водители. Для банка — счета, транзакции, клиенты.

2. Поддомен (Subdomain)

Разбиваем домен на части:

  • Core — самое важное, конкурентное преимущество (для Uber — алгоритм подбора водителей)

  • Supporting — важно, но не уникально (платежи, уведомления)

  • Generic — стандартные задачи (логирование, аутентификация)

3. Ограниченный контекст (Bounded Context)

Самая важная концепция! Это граница, внутри которой у терминов есть четкое значение.

Пример: термин "пользователь" в разных контекстах:

# Контекст "Аутентификация"
class User:
    username: str
    password_hash: str
    email: str
    is_active: bool

# Контекст "Доставка"
class Customer:
    customer_id: str
    delivery_address: Address
    preferred_time: TimeWindow
    contact_phone: str

# Контекст "Бухгалтерия"
class Client:
    tax_id: str
    billing_address: Address
    payment_terms: str
    credit_limit: Decimal

Важно: Это три разных класса! Они не связаны напрямую.

4. Агрегат (Aggregate) — транзакционная граница

Группа объектов, которые меняются как единое целое.

# Агрегат "Заказ"
class Order:
    def __init__(self, id: OrderId, customer: Customer):
        self.id = id
        self.customer = customer
        self.items: list[OrderItem] = []
        self.payments: list[Payment] = []
        self.status: OrderStatus
    
    def add_item(self, product: Product, quantity: int):
        # Правило: нельзя менять оплаченный заказ
        if self.status == OrderStatus.PAID:
            raise DomainError("Cannot modify paid order")
        
        item = OrderItem(product, quantity)
        self.items.append(item)
    
    def apply_payment(self, payment: Payment):
        self.payments.append(payment)
        
        total_paid = sum(p.amount for p in self.payments)
        if total_paid >= self.total_amount:
            self.status = OrderStatus.PAID
            self.add_event(OrderPaidEvent(order_id=self.id))

Корень агрегата (Aggregate Root) — единственная точка входа для изменений. Чтобы изменить OrderItem, нужно работать через Order.

5. Репозиторий (Repository) — абстракция над хранилищем

class OrderRepository(ABC):
    @abstractmethod
    def get(self, order_id: OrderId) -> Order:
        pass
    
    @abstractmethod
    def save(self, order: Order) -> None:
        pass

# Реализация для PostgreSQL
class PostgresOrderRepository(OrderRepository):
    def __init__(self, session):
        self.session = session
    
    def get(self, order_id: OrderId) -> Order:
        # Преобразуем данные БД в доменный объект
        data = self.session.query(OrderModel).filter_by(id=order_id).first()
        return OrderMapper.to_domain(data)
    
    def save(self, order: Order) -> None:
        # Сохраняем агрегат целиком
        data = OrderMapper.to_persistence(order)
        self.session.merge(data)
        
        # Сохраняем domain events
        for event in order.events:
            event_bus.publish(event)
        
        order.clear_events()

6. Сервис домена (Domain Service) — операция, не принадлежащая одному объекту

class PaymentProcessingService:
    def process_payment(self, order: Order, payment_method: PaymentMethod):
        # Сложная логика, затрагивающая несколько агрегатов
        payment = Payment.create(order.total, payment_method)
        
        fraud_check = self.fraud_detector.check(payment)
        if fraud_check.is_high_risk:
            order.mark_as_suspicious()
        
        payment.process()
        order.apply_payment(payment)
        
        if payment.is_successful:
            self.shipping_service.schedule_delivery(order)

Сравнение с другими подходами

DDD vs CRUD (Data-Driven)

CRUD приложение

DDD приложение

Проектируем таблицы

Проектируем доменную модель

Сервисы работают с БД напрямую

Сервисы работают с репозиториями

Бизнес-логика в сервисах

Бизнес-логика в доменных объектах

"Тонкие" модели (DTO)

"Толстые" модели с поведением

Изменение БД = изменение кода

Модель инкапсулирует сложность

DDD vs Clean/Hexagonal Architecture

  • DDD — про что моделировать (бизнес-понятия)

  • Clean/Hexagonal — про как организовать код (слои, зависимости)

Они прекрасно дополняют друг друга!

# Пример об��единения DDD + Hexagonal
src/
├── domain/           # Чистая бизнес-логика (DDD)
│   ├── order.py      # Агрегаты, entities, value objects
│   ├── services.py   # Domain services
│   └── events.py     # Domain events
├── application/      # Use cases, orchestration
│   └── use_cases/
│       └── place_order.py
├── infrastructure/   # Реализации репозиториев, внешние API
│   ├── repositories/
│   │   └── postgres_order_repo.py
│   └── api_clients/
│       └── payment_gateway_client.py
└── presentation/     # Controllers, API endpoints
    └── api/
        └── order_controller.py

Когда использовать DDD?

Используйте DDD когда:

  1. Сложная бизнес-логика (финансы, логистика, медицина)

  2. Часто меняются бизнес-правила

  3. Нужна долгосрочная поддержка

  4. Есть предметные эксперты (domain experts)

Не используйте DDD когда:

  1. Простой CRUD (админка, блог)

  2. Прототип или MVP

  3. Нет экспертов предметной области

  4. Сжатые сроки и маленькая команда

Реальный пример: система бронирования отелей

Без DDD:

def book_room(user_id, room_id, dates):
    # 500 строк SQL и проверок...

С DDD:

# Домен
class Booking:
    def __init__(self, guest: Guest, room: Room, period: DateRange):
        self.guest = guest
        self.room = room
        self.period = period
        self.status = BookingStatus.PENDING
        
    def confirm(self, payment: Payment):
        if not self.room.is_available(self.period):
            raise DomainError("Room not available")
        
        if self.guest.has_overdue_bookings():
            raise DomainError("Guest has overdue bookings")
        
        if self.period.length > 30 and not payment.is_guaranteed():
            raise DomainError("Long stays require guarantee")
        
        self.status = BookingStatus.CONFIRMED
        self.confirmation_date = datetime.now()
        
        self.add_event(RoomBookedEvent(
            room_id=self.room.id,
            period=self.period,
            guest_id=self.guest.id
        ))

Типичные ошибки новичков

  1. Слишком много агрегатов — начинают делать агрегат для каждой таблицы

  2. Анемичная модель — данные без поведения (getters/setters)

  3. Игнорирование bounded contexts — одна огромная модель на всё приложение

  4. Репозиторий на каждый объект — нарушают целостность агрегата

Практический совет с чего начать

  1. Начните с событий — спросите: "Какие важные события происходят в системе?"

  2. Выделите один bounded context — самый важный для бизнеса

  3. Сделайте "толстую" модель — перенесите 2-3 бизнес-правила из сервисов в доменные объекты

  4. Внедряйте постепенно — не нужно переписывать всё сразу

Итог

DDD — это не про сложные диаграммы и умные слова. Это про:

  1. Говорить на языке бизнеса (единый язык — Ubiquitous Language)

  2. Изолировать сложность (bounded contexts)

  3. Инкапсулировать правила (в агрегатах)

  4. Четко разделять ответственность (домен/приложение/инфраструктура)

Самый важный вопрос для проверки: Можете ли вы объяснить логику своего кода бизнес-аналитику без упоминания баз данных, API и фреймворков?

Если да — вы на пути к DDD. Если нет — у вас Data-Driven Design.

А какой подход используете вы? Сталкивались ли с попытками внедрить DDD? Делитесь опытом в комментариях!