ddd-building-blocks: DDD-абстракции для Java

ddd-building-blocks: библиотека DDD-абстракций для Java.

Статья внедрена в скилл AI-агента ucp-ddd-tactical-review / ucp-ddd-tactical-design Эталонная библиотека к статье ddd-building-blocks ddd-building-blocks Java

ddd-building-blocks Java — когда начинаешь применять DDD в Java-проекте, первый вопрос — где взять базовые абстракции? Писать свои Entity, AggregateRoot, DomainEvent в каждом проекте заново? Тащить Spring Data или Axon ради пары интерфейсов?

Я написал библиотеку ddd-building-blocks — минимальный набор абстракций для DDD. Zero dependencies, чистая Java. Подключаешь одну зависимость — и получаешь готовый каркас для доменной модели.

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

ddd-building-blocks Java: подключение

dependencies {
    implementation("ru.badgermock:ddd-building-blocks:1.0.0")
}

Что внутри

diagram

Библиотека содержит 8 абстракций — ровно столько, сколько нужно для полноценной доменной модели, и ни одной лишней.

Entity — сущность с идентичностью

Entity идентифицируется по ID, а не по атрибутам. В библиотеке equals/hashCode реализованы в базовом классе и финализированы — переопределить нельзя. Это исключает частую ошибку, когда разработчик сравнивает сущности по полям вместо ID.

public class Product extends Entity {

    private final ProductId id;
    private String name;
    private Money price;

    public Product(ProductId id, String name, Money price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    @Override
    public ProductId getId() {
        return id;
    }

    public void changePrice(Money newPrice) {
        this.price = newPrice;
    }
}

// Два Product с одинаковым ProductId — одна и та же сущность
Product a = new Product(productId, "Молоко", money(99));
Product b = new Product(productId, "Молоко", money(149));
a.equals(b); // true — тот же ID

ValueObject — объект-значение

Value Object определяется атрибутами, иммутабелен. Реализуется через Java recordequals, hashCode и toString генерируются автоматически.

public record Money(BigDecimal amount, String currency) implements ValueObject {

    public Money {
        Objects.requireNonNull(amount, "amount must not be null");
        Objects.requireNonNull(currency, "currency must not be null");
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add different currencies");
        }
        return new Money(amount.add(other.amount), currency);
    }
}

AggregateRoot — корень агрегата

Aggregate Root — граница консистентности. Все изменения вложенных Entity и ValueObject проходят через корень. В библиотеке AggregateRoot поддерживает доменные события (registerEvent) и версионирование для оптимистичной блокировки.

public class Order extends AggregateRoot {

    private final OrderId id;
    private final List lines = new ArrayList<>();
    private OrderStatus status;

    public Order(OrderId id) {
        this.id = id;
        this.status = OrderStatus.DRAFT;
    }

    @Override
    public OrderId getId() {
        return id;
    }

    public void place() {
        if (lines.isEmpty()) {
            throw new IllegalStateException("Cannot place an empty order");
        }
        this.status = OrderStatus.PLACED;
        registerEvent(new OrderPlacedEvent(id, calculateTotal()));
    }

    public void addLine(ProductId productId, int quantity, Money price) {
        lines.add(new OrderLine(productId, quantity, price));
    }
}

DomainEvent — доменное событие

Domain Event фиксирует факт, который произошёл в домене. Несёт информацию об агрегате-источнике. Два конструктора: для создания новых событий (UUID и timestamp генерируются автоматически) и для восстановления из хранилища.

public class OrderPlacedEvent extends DomainEvent {

    private final Money totalAmount;

    // Новое событие
    public OrderPlacedEvent(OrderId orderId, Money totalAmount) {
        super("Order", orderId.toString());
        this.totalAmount = totalAmount;
    }

    // Восстановление из хранилища / message bus
    public OrderPlacedEvent(UUID eventId, Instant occurredAt,
                            String aggregateId, Money totalAmount) {
        super(eventId, occurredAt, "Order", aggregateId);
        this.totalAmount = totalAmount;
    }

    public Money getTotalAmount() {
        return totalAmount;
    }
}

DomainEventPublisher и DomainEventHandler

Домен определяет контракт публикации, инфраструктура реализует. DomainEventHandler — функциональный интерфейс, можно использовать как лямбду. Подробнее о подписчиках и @TransactionalEventListener — в статье об интеграционных паттернах.

// Реализация через Spring Events
public class SpringEventPublisher implements DomainEventPublisher {

    private final ApplicationEventPublisher applicationEventPublisher;

    public SpringEventPublisher(ApplicationEventPublisher publisher) {
        this.applicationEventPublisher = publisher;
    }

    @Override
    public void publish(DomainEvent event) {
        applicationEventPublisher.publishEvent(event);
    }
}

// Типизированный обработчик
public class SendConfirmationOnOrderPlaced
        implements DomainEventHandler {

    private final NotificationService notifications;

    @Override
    public void handle(OrderPlacedEvent event) {
        notifications.sendOrderConfirmation(
            event.getAggregateId(),
            event.getTotalAmount()
        );
    }
}

AggregateRepository — репозиторий агрегатов

Интерфейс персистенции для write-side. Работает только с AggregateRoot. Блокировки — ответственность реализации. Для read-side (запросы, DTO, проекции) общая абстракция не предусмотрена — каждый сервис определяет свои query-интерфейсы напрямую через jOOQ/SQL. Это стандартный подход CQRS: write-side формализован, read-side свободен.

// Доменный интерфейс
public interface OrderRepository extends AggregateRepository {

    List findByStatus(OrderStatus status);

    // Намерение, не механизм
    Optional findByIdForModification(OrderId id);
}

// Реализация на jOOQ с публикацией событий
public class JooqOrderRepository implements OrderRepository {

    private final DSLContext dsl;
    private final DomainEventPublisher eventPublisher;

    @Override
    public Order save(Order aggregate) {
        dsl.update(ORDERS)
            .set(ORDERS.STATUS, aggregate.getStatus().name())
            .set(ORDERS.TOTAL_AMOUNT, aggregate.getTotalAmount())
            .set(ORDERS.UPDATED_AT, LocalDateTime.now())
            .where(ORDERS.ID.eq(aggregate.getId().getValue()))
            .execute();

        eventPublisher.publishAll(aggregate.getEvents());
        aggregate.clearDomainEvents();
        return aggregate;
    }

    @Override
    public Optional findByIdForModification(OrderId id) {
        return dsl.selectFrom(ORDERS)
            .where(ORDERS.ID.eq(id.getValue()))
            .forUpdate()
            .fetchOptional(this::toOrder);
    }
}

Specification — спецификация бизнес-правил

Specification инкапсулирует бизнес-правила. Поддерживает композицию через and, or, not.

public class OrderIsOverdue extends Specification {
    @Override
    public boolean isSatisfiedBy(Order order) {
        return order.getDueDate().isBefore(Instant.now());
    }
}

public class OrderIsUnpaid extends Specification {
    @Override
    public boolean isSatisfiedBy(Order order) {
        return !order.isPaid();
    }
}

// Композиция
Specification needsAttention = new OrderIsOverdue()
    .and(new OrderIsUnpaid());

List urgent = orders.stream()
    .filter(needsAttention::isSatisfiedBy)
    .toList();

Почему не Spring Data / Axon / jMolecules?

Существующие решения либо слишком тяжёлые, либо привязывают к конкретному фреймворку:

  • Spring DataAbstractAggregateRoot привязан к Spring. Нельзя использовать в модуле без Spring-контекста.
  • Axon Framework — полноценный CQRS/ES-фреймворк. Избыточен, если нужны только базовые абстракции.
  • jMolecules — аннотации для документирования, но не несут поведения (equals/hashCode для Entity, registerEvent для Aggregate).

ddd-building-blocks — это золотая середина: реальное поведение (не просто маркерные аннотации), zero dependencies (работает с любым фреймворком) и минимальный API (8 абстракций, которые помещаются в голове).

Связь с серией статей

Каждая абстракция библиотеки реализует конкретный паттерн DDD. Вот карта соответствий:

Принципы проектирования API библиотеки (Intention-Revealing Interfaces, Closure of Operations) описаны в статье Принципы проектирования в DDD.


Репозиторий: github.com/remodov/ddd-building-blocks

Пакет: ru.badgermock:ddd-building-blocks:1.0.0