Принципы проектирования в DDD

Принципы проектирования в DDD: инварианты, границы транзакций, eventual consistency.

принципы проектирования DDD

принципы проектирования DDD — паттерны DDD (Entity, Aggregate, Bounded Context) отвечают на вопрос «что строить». Эта статья — о другом: как думать при проектировании доменной модели. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в отдельные паттерны, но определяют качество архитектуры.

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

принципы проектирования DDD: 1. Перемалывание знаний (Knowledge Crunching)

Модель — это совместный артефакт разработчиков и доменных экспертов. Она растёт из диалога и постоянно уточняется. Важнее понять причинно-следственные связи в домене, чем быстро построить «правильные» классы.

Практики

  • Совместные модел-сессии (whiteboard, Event Storming)
  • Быстрый прототип → обратная связь → изменение модели
  • Явное именование правил и политик
// ✅ Явная доменная категория — знание в коде
class CustomerTierResolver {
    CustomerTier resolve(Customer customer) {
        if (customer.ordersInLastYear() >= 20) return CustomerTier.GOLD;
        if (customer.ordersInLastYear() >= 5)  return CustomerTier.SILVER;
        return CustomerTier.BRONZE;
    }
}

// ❌ Знание спрятано в SQL — домен «протекает» в DAO
class OrderDao {
    RiskRating riskFor(OrderId id) {
        // SELECT ... CASE WHEN ...
        return RiskRating.LOW;
    }
}

// ❌ Копирование структуры БД вместо модели
class OrderRecord {
    Long id;
    BigDecimal total_amount;
    String status_code;
    // никаких правил и поведения
}

Главный риск: зафиксировать модель слишком рано или скопировать структуру БД как доменную модель. Ранние модели наивны — и это нормально, их нужно активно пересматривать.


2. Связь модели и реализации (Model-Driven Design)

Модель должна быть выражена в коде, иначе язык и дизайн расходятся. Если модель существует только на вики или в голове аналитика — это не Model-Driven Design.

// ✅ Model-Driven Design: код = модель
class Order {
    private OrderStatus status;

    void confirm() {
        if (!canBeConfirmed())
            throw new IllegalStateException("Order cannot be confirmed");
        status = OrderStatus.CONFIRMED;
    }

    void markAsPaid() {
        status = OrderStatus.PAID;
        events.add(new OrderPaidEvent(id));
    }
}

// ❌ Анемичная модель: сущность без поведения + сервис-контейнер логики
class Order {
    Long id;
    OrderStatus status;
    /* только get/set */
}

class OrderService {
    void confirm(Order order) { /* вся логика здесь */ }
}

Анемичная модель — самая частая причина деградации доменной логики. Бизнес-правила утекают в сервисы, контроллеры, ORM-маппинги, и со временем непонятно, где искать правду.


3. Изоляция домена (слоистая архитектура)

Слои защищают домен от инфраструктуры и UI. Направление зависимостей: UI → Application → Domain → Infrastructure. Доменные правила не должны зависеть от фреймворков.

diagram
// ✅ Application Service координирует, домен решает
class OrderAppService {
    private final OrderRepository repo;

    void confirmOrder(OrderId id) {
        Order order = repo.byId(id);
        order.confirm();       // решение — в домене
        repo.save(order);
    }
}

// ✅ Репозиторий: интерфейс в домене, реализация в инфраструктуре
class JpaOrderRepository implements OrderRepository {
    public Order byId(OrderId id) { /* JPA */ }
    public void save(Order order) { /* JPA */ }
}

// ❌ Домен зависит от инфраструктуры
@Entity
class Order { /* JPA-аннотации внутри домена */ }

// ❌ Application Service работает напрямую с EntityManager
class OrderAppService {
    @PersistenceContext EntityManager em;
}

4. Прорывы (Breakthroughs)

Прорыв — это изменение модели, которое резко упрощает дизайн. Обычно это происходит, когда вы замечаете скрытую концепцию и делаете её явной. До прорыва код сложный и хрупкий. После — проще и выразительнее.

// ❌ До прорыва: скидка — просто флаг
class Order {
    boolean hasDiscount;
    boolean canCancel;
}

// ✅ После прорыва: скидка и политика отмены — доменные концепции
class Discount {
    private final Money amount;

    boolean appliesTo(Order order) { /* ... */ }
}

class CancellationPolicy {
    boolean canCancel(Order order) { /* ... */ }
}

class Order {
    void apply(Discount discount) { /* ... */ }
}

Как искать прорывы: ищите скрытые концепции — роли, политики, периоды, статусы. Если участок модели «слишком сложный» — это кандидат на переосмысление. Прорыв требует рефакторинга, даже если больно. Главный риск — держаться за старую модель из-за стоимости изменений.


5. Делать скрытое явным

Важные правила и концепции домена должны стать объектами модели, а не прятаться в if/else. Кандидаты на выделение: политики, роли, временные периоды, доменные события.

// ❌ Невыразительная логика без имени
if (amount.compareTo(limit) <= 0 && !customer.isBlocked()) { /* ... */ }

// ✅ Политика — именованное правило
class CreditLimitPolicy {
    boolean allows(Customer customer, Money amount) {
        return customer.limit().remaining().isGreaterThan(amount);
    }
}

// ✅ Период — доменная концепция вместо двух LocalDate
record BillingPeriod(LocalDate from, LocalDate to) {
    boolean includes(LocalDate date) {
        return !date.isBefore(from) && !date.isAfter(to);
    }
}

// ✅ Событие — факт, а не побочный эффект
class OrderExpired implements DomainEvent { /* ... */ }

Явные типы упрощают тестирование и повторное использование. Давайте имя каждому важному правилу.


6. Гибкий дизайн (Supple Design)

Набор из шести принципов, которые делают доменную модель пластичной и точной. Это, пожалуй, самая недооценённая часть книги Эванса.

6.1 Intention-Revealing Interfaces

Публичный API говорит что происходит, а не как это сделано.

// ✅ Методы раскрывают намерение
class Order {
    void confirm() { /* ... */ }
    void cancelByCustomer(Reason reason) { /* ... */ }
    void markAsPaid(PaymentId paymentId) { /* ... */ }
}

// ❌ Метод скрывает смысл
class Order {
    void updateStatus(int code) { /* ... */ }
}

6.2 Side-Effect-Free Functions

Вычисления не должны менять состояние. Проще тестировать и переиспользовать.

// ✅ Чистое вычисление — не меняет ничего
class TaxCalculator {
    Money taxFor(Order order) { /* pure calculation */ }
}

// ❌ Вычисление + побочный эффект в одном методе
class PricingService {
    Money calculateAndApplyDiscount(Order order) { /* меняет order */ }
}

6.3 Assertions / Contracts

Инварианты проверяются там, где меняется состояние. Ошибки ловятся ближе к источнику.

// ✅ Инварианты на входе
class Account {
    void withdraw(Money amount) {
        if (amount.isNegative()) throw new IllegalArgumentException();
        if (balance.isLessThan(amount)) throw new IllegalStateException();
        balance = balance.subtract(amount);
    }
}

// ❌ Молча позволяет невалидное состояние
class Account {
    void withdraw(Money amount) {
        balance = balance.subtract(amount); // отрицательный баланс? ок...
    }
}

6.4 Conceptual Contours

Модель делится на устойчивые смысловые части. Изменения локализуются, меньше связей.

// ✅ Каждый контур — отдельная ответственность
interface PricingPolicy {
    Money priceFor(Order order);
}

interface ShippingPolicy {
    Money costFor(Shipment shipment);
}

// ❌ Все правила в одном месте
class OrderService {
    void price() { }
    void ship() { }
    void invoice() { }
}

6.5 Declarative Design

Правило выражено как объект, а не как процедурная цепочка if/else. Можно переиспользовать и комбинировать.

// ✅ Правило — объект
class CanConfirmOrder implements Specification {
    public boolean isSatisfiedBy(Order order) {
        return order.isPaid() && order.hasItems();
    }
}

// ❌ Правило — процедурная каша
if (paid && !items.isEmpty() && !isBlocked) { /* ... */ }

6.6 Closure of Operations

Операции возвращают объекты доменного языка, а не примитивы. Легче композиция.

// ✅ Money → Money (замкнутость)
Money addTax(Money base) {
    return base.add(base.multiply(taxRate));
}

// ❌ double → double (теряем доменный тип)
double addTax(double base) {
    return base + base * 0.2;
}

7. Аналитические паттерны

Повторяющиеся доменные структуры, которые помогают распознать форму домена. Это не рецепты, а отправные точки для разговора с экспертами.

// Party/Role — универсальная структура «участник + роль»
class Party {
    private final PartyId id;
}

class CustomerRole {
    private final Party party;
}

// Quantity — значение с единицей измерения
record Quantity(BigDecimal value, Unit unit) {
    Quantity add(Quantity other) { /* проверка unit */ }
}

// Observation — измерение во времени
class Observation {
    private final Instant time;
    private final Quantity value;
}

// ❌ Паттерн применён без смысла
class PartyRole {
    String type; /* используется как универсальный тип для всего */
}

Ключевые паттерны: Party/Role (участники и их роли), Accountability (ответственность), Observation (измерения), Quantity (значения с единицами). Используйте для разговора с экспертами, а не для слепого копирования.


8. Связь с паттернами проектирования (GoF)

Технические паттерны (Strategy, Factory, Specification) должны поддерживать модель домена, а не подменять её. Паттерн оправдан, если подчёркивает смысл домена.

// Strategy — для вариативного поведения домена
interface ShippingPolicy {
    Money costFor(Shipment shipment);
}

class ExpressShipping implements ShippingPolicy {
    public Money costFor(Shipment shipment) { /* ... */ }
}

// Factory — для создания с доменными правилами
class ShippingPolicyFactory {
    ShippingPolicy forOrder(Order order) { /* выбор политики */ }
}

// ❌ Паттерн ради паттерна — Singleton без доменного смысла
class ConfigManager {
    static ConfigManager INSTANCE = new ConfigManager();
}

Рефакторинг — не техническое упражнение, а инструмент углубления модели. Каждый рефакторинг должен приближать код к языку домена.


Итого: принципы проектирования

  • Модель растёт из диалога — не фиксируйте её рано, постоянно уточняйте с экспертами
  • Код = модель — анемичная модель с сервисами-контейнерами логики — антипаттерн
  • Домен изолирован — зависимости направлены к домену, не от него
  • Ищите прорывы — скрытые концепции, которые упрощают модель
  • Делайте скрытое явным — политики, периоды, роли заслуживают собственных типов
  • API раскрывает намерениеconfirm(), не updateStatus(2)
  • Разделяйте вычисления и команды — чистые функции отдельно от изменений состояния
  • Паттерны поддерживают модель — Strategy и Factory оправданы, когда подчёркивают домен

Эти принципы — фундамент, на котором строятся тактические, стратегические и интеграционные паттерны DDD.