DDD-спецификация: гайд для разработчика

Реализация DDD на Java/Spring Boot: модель, commands, events, API.

DDD гайд разработчик Java

DDD гайд разработчик Java — этот гайд собирает все блоки "Часть для разработчика" из шаблона спецификации, реорганизованные по порядку реализации. Рядом с каждым разделом -- примеры кода на Java (Spring Boot, JPA). Для тестирования реализации см. Гайд по тестированию.

Роль разработчика -- транслировать доменную модель и бизнес-правила из спецификации в работающий код, сохраняя единый язык (Ubiquitous Language) на всех уровнях: от имен классов до сообщений об ошибках.


DDD гайд разработчик Java: 1. Структура проекта: пакеты, модули, адаптеры

Bounded Context транслируется в структуру модулей и пакетов:

  • Корневой пакет com.shop.order -- вся логика заказов здесь
  • Для каждого соседнего контекста -- адаптер в пакете com.shop.order.infrastructure.adapter (например: CatalogServiceAdapter, PaymentGatewayAdapter)
  • Модуль не должен напрямую импортировать классы из других контекстов -- только через интерфейсы и DTO
  • Диаграмма C1 = список Spring-бинов: по одному адаптеру на каждую стрелку к внешней системе

Рекомендуемая структура пакетов:

com.shop.order
  ├── domain
  │   ├── model          # Order, OrderItem, Money, DeliveryAddress, ProductSnapshot
  │   ├── event          # DomainEvent, OrderCreated, OrderConfirmed, ...
  │   └── exception      # OrderEmptyException, InvalidQuantityException, ...
  ├── application
  │   ├── command         # CreateOrderCommand, ConfirmOrderCommand, ...
  │   ├── query           # OrderSearchCriteria, OrderSummary
  │   └── service         # OrderApplicationService
  ├── infrastructure
  │   ├── adapter         # CatalogServiceAdapter, PaymentGatewayAdapter
  │   ├── persistence     # JPA entities, repositories, OutboxEvent
  │   └── config          # SecurityConfig, CacheConfig, KafkaConfig
  └── api
      ├── controller      # OrderController
      ├── dto             # OrderResponse, ErrorResponse
      └── mapper          # OrderResponseMapper

2. Доменная модель: Order, OrderItem, Money, DeliveryAddress, ProductSnapshot

Доменная модель транслируется в Java-классы:

  • Пакет: com.shop.order.domain.model
  • Aggregate Root: класс Order с private полями, все изменения через методы (addItem(), confirm(), cancel())
  • Entity: OrderItem -- создается только через Order.addItem()
  • Value Objects: record Money(BigDecimal amount, Currency currency) -- immutable
  • Enum: OrderStatus с методом canTransitionTo(OrderStatus target)
  • JPA: @Embedded для VO, @OneToMany(cascade = ALL, orphanRemoval = true) для OrderItem

Value Object как record:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount must be >= 0");
        }
        Objects.requireNonNull(currency, "Currency must not be null");
    }
}

Aggregate Root -- класс Order с private-полями, все мутации через доменные методы. OrderItem -- Entity внутри агрегата, создается только через Order.addItem(). VO (Money, DeliveryAddress, ProductSnapshot) маппятся через @Embedded / @AttributeOverrides.


3. State machine: OrderStatus

Реализация state machine в коде:

public enum OrderStatus {
    CREATED, CONFIRMED, PAID, SHIPPED, DELIVERED, CANCELLED;

    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
        CREATED, Set.of(CONFIRMED, CANCELLED),
        CONFIRMED, Set.of(PAID, CANCELLED),
        PAID, Set.of(SHIPPED),
        SHIPPED, Set.of(DELIVERED)
    );

    public boolean canTransitionTo(OrderStatus target) {
        return TRANSITIONS.getOrDefault(this, Set.of()).contains(target);
    }
}

Правила использования:

  • В Order: каждый метод-команда проверяет допустимость перехода и бросает IllegalOrderStateException
  • Optimistic locking: @Version private int version; в JPA-сущности -- защита от конкурентных изменений при большом количестве позиций (50+)

Пример вызова в доменном методе:

public void confirm() {
    if (!status.canTransitionTo(CONFIRMED)) {
        throw new IllegalOrderStateException(status, CONFIRMED);
    }
    if (items.isEmpty()) {
        throw new OrderEmptyException();
    }
    this.status = CONFIRMED;
    registerEvent(new OrderConfirmed(this.id, OffsetDateTime.now()));
}

4. Бизнес-правила: валидация и исключения

Каждое бизнес-правило (BR) транслируется в код валидации в доменной модели:

BRГде проверятьКод
BR-1Order.confirm()if (items.isEmpty()) throw new OrderEmptyException();
BR-2Конструктор OrderItemif (quantity < 1 \|\| quantity > 99) throw new InvalidQuantityException();
BR-3Order.recalculateTotal()Проверка после пересчета суммы
BR-4Order.cancel()if (!status.canTransitionTo(CANCELLED)) throw new OrderCancelForbiddenException();
BR-5Order.addItem()findExistingItem(productId).ifPresent(item -> item.increaseQuantity(quantity))
BR-6Order.addItem() / removeItem()if (status != CREATED) throw new OrderModificationForbiddenException();

Исключения типизированные, каждое маппится на код ошибки из каталога ошибок (раздел 13 спецификации). Пример иерархии:

public abstract class OrderDomainException extends RuntimeException {
    public abstract String getErrorCode();
}

public class OrderEmptyException extends OrderDomainException {
    @Override
    public String getErrorCode() { return "ORDER_EMPTY"; }
}

public class OrderCancelForbiddenException extends OrderDomainException {
    @Override
    public String getErrorCode() { return "ORDER_CANCEL_FORBIDDEN"; }
}

5. Авторизация: Spring Security, роли, фильтрация данных

Реализация авторизации:

  • Spring Security с @PreAuthorize("hasRole('MANAGER')") на методах сервиса
  • Фильтрация данных: для Customer -- orderRepository.findByCustomerId(currentUser.getId())
  • Enum Role: CUSTOMER, MANAGER, ADMIN -- маппится из JWT claim
  • Guard в доменной модели: Order.cancel(UserId cancelledBy) проверяет cancelledBy == customerId

Матрица доступа из спецификации транслируется напрямую в аннотации:

@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
public OrderResponse createOrder(CreateOrderCommand cmd, UserId currentUser) { ... }

@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
public void shipOrder(UUID orderId, String trackingNumber) { ... }

Для Customer все запросы автоматически фильтруются по customerId. Для Manager -- по региону. Для Admin -- без ограничений. При запросе чужого заказа Customer получает 404 (а не 403 -- чтобы не подтверждать существование ресурса).


6. Commands: DTO, Application Service, Domain method

Каждая команда из спецификации реализуется как тройка: DTO + метод сервиса + доменный метод.

  • DTO: record CreateOrderCommand(List<OrderItemRequest> items, DeliveryAddressRequest address) с Bean Validation
  • Application service: OrderApplicationService.createOrder(cmd, currentUser) -- оркестрация
  • Domain method: Order.create(...) -- фабричный метод, проверяет BR
  • REST controller: @PostMapping("/api/v1/orders") -> application service -> OrderResponse
  • Событие: после orderRepository.save(order) публиковать через Outbox

Пример application service метода:

@Service
@RequiredArgsConstructor
public class OrderApplicationService {

    private final OrderRepository orderRepository;
    private final CatalogPort catalogAdapter;
    private final EventPublisher eventPublisher;

    @Transactional
    public OrderResponse createOrder(CreateOrderCommand cmd, UserId currentUser) {
        var products = catalogAdapter.getProducts(cmd.productIds());
        var order = Order.create(currentUser, cmd.items(), cmd.address(), products);
        orderRepository.save(order);
        eventPublisher.publishAll(order.getDomainEvents());
        return OrderResponseMapper.toResponse(order);
    }
}

Альтернативные потоки (ошибки валидации, недоступность внешних сервисов) обрабатываются через @ExceptionHandler в контроллере.


7. Domain Events: базовый класс, Outbox, publishing

Реализация доменных событий:

  • Базовый класс: abstract class DomainEvent { UUID eventId; OffsetDateTime occurredAt; }
  • Порождение: Order накапливает события в List<DomainEvent>. Application service после save() публикует
  • Outbox entity: @Entity OutboxEvent { UUID id; String type; UUID aggregateId; String payload; OffsetDateTime createdAt; boolean published; }
  • Kafka producer: читает из outbox, отправляет в topic order-events, помечает published = true

Пример базового класса и Outbox entity:

public abstract class DomainEvent {
    private final UUID eventId = UUID.randomUUID();
    private final OffsetDateTime occurredAt = OffsetDateTime.now();

    public UUID getEventId() { return eventId; }
    public OffsetDateTime getOccurredAt() { return occurredAt; }
}

@Entity
@Table(name = "domain_events_outbox")
public class OutboxEvent {
    @Id
    private UUID id;
    private String eventType;
    private UUID aggregateId;

    @Column(columnDefinition = "jsonb")
    private String payload;

    private OffsetDateTime createdAt;
    private boolean published;
}

Внутренние события (например, ItemAdded) обрабатываются синхронно внутри контекста. Внешние (OrderCreated, OrderConfirmed, OrderPaid, OrderCancelled, OrderShipped, RefundInitiated) публикуются через Kafka с Transactional Outbox pattern.


8. Queries: Repository, Specification, JPA Projection

Реализация запросов:

  • Repository: OrderReadRepository с findById(), search(OrderSearchCriteria)
  • JPA Projection: interface OrderSummary { UUID getId(); OrderStatus getStatus(); int getItemsCount(); Money getTotalAmount(); }
  • Specification pattern: динамические фильтры через OrderSpecification
  • REST: @GetMapping("/api/v1/orders/{id}") и @GetMapping("/api/v1/orders") с @RequestParam

Фильтры для SearchOrders: status (enum), customerId (UUID), dateFrom / dateTo (OffsetDateTime). Сортировка по дате (по умолчанию) или сумме. Пагинация через page и size (по умолчанию 20).


9. REST API: Controllers, GlobalExceptionHandler, ErrorResponse

Маппинг команд и запросов на эндпоинты:

ЭндпоинтМетодКоманда / Запрос
/api/v1/ordersPOSTCreateOrder
/api/v1/orders/{orderId}/confirmPUTConfirmOrder
/api/v1/orders/{orderId}/itemsPOSTAddItem
/api/v1/orders/{orderId}/cancelPUTCancelOrder
/api/v1/orders/{orderId}/shipPUTShipOrder
/api/v1/orders/{orderId}/deliverPUTDeliverOrder
/api/v1/orders/{orderId}/refundPOSTInitiateRefund
/api/v1/orders/{orderId}GETGetOrder
/api/v1/ordersGETSearchOrders

Каждая строка каталога ошибок -> класс исключения + маппинг в GlobalExceptionHandler:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(OrderValidationException ex,
                                                          HttpServletRequest request) {
        var body = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            OffsetDateTime.now(),
            request.getRequestURI()
        );
        return ResponseEntity.status(422).body(body);
    }

    @ExceptionHandler(OrderStateConflictException.class)
    public ResponseEntity<ErrorResponse> handleConflict(OrderStateConflictException ex,
                                                        HttpServletRequest request) {
        var body = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            OffsetDateTime.now(),
            request.getRequestURI()
        );
        return ResponseEntity.status(409).body(body);
    }
}

public record ErrorResponse(String code, String message,
                             OffsetDateTime timestamp, String path) {}

Правила логирования: 4xx -- WARN, 5xx -- ERROR с полным stack trace. Клиенту никогда не возвращаются stack traces, SQL-запросы или внутренние пути.

Реестр экранов транслируется в REST endpoints: каждый запрос -> GET, каждая команда -> POST/PUT. Ошибки отображаются как JSON { "code": "ORDER_EMPTY", "message": "..." }.


10. Saga: OrderProcessingSaga, RefundSaga, SagaState, Spring Retry

Реализация многошаговых процессов:

  • Класс: OrderProcessingSaga / RefundSaga с orderId, currentStep, status
  • Persistence: @Entity SagaState { UUID sagaId; String sagaType; String currentStep; String payload; OffsetDateTime startedAt; }
  • Event listener: @EventListener на OrderConfirmed -> запуск Saga
  • Retry: Spring Retry @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))

Пример:

@Component
@RequiredArgsConstructor
public class OrderProcessingSaga {

    private final SagaStateRepository sagaStateRepository;
    private final WarehousePort warehouseAdapter;
    private final PaymentPort paymentAdapter;

    @EventListener
    @Transactional
    public void handle(OrderConfirmed event) {
        var state = SagaState.start("ORDER_PROCESSING", event.orderId());
        sagaStateRepository.save(state);
        reserveStock(state, event);
    }

    @Retryable(maxAttempts = 3,
               backoff = @Backoff(delay = 1000, multiplier = 2))
    private void reserveStock(SagaState state, OrderConfirmed event) {
        warehouseAdapter.reserve(event.orderId(), event.items());
        state.completeStep("RESERVE_STOCK");
    }
}

Saga 1 (Обработка заказа): резерв на складе -> оплата -> создание заявки на доставку. При ошибке оплаты -- компенсация (отмена резерва). Saga 2 (Возврат): возврат средств -> возврат товара на склад.

Таймауты: 30 секунд на резерв и оплату, 5 минут на доставку. После 3 неудач -- компенсация + алерт.


11. Именование: глоссарий как справочник

Глоссарий из спецификации -- справочник для именования классов, методов, переменных и таблиц:

  • Колонка "Класс в коде" -- именно так называть доменные классы: Order, OrderItem, ProductSnapshot
  • Методы тоже следуют глоссарию: order.addItem(), а не order.addProduct()
  • Антислова -- повод для ArchUnit-правила, запрещающего классы с именами из колонки "Не используем"

Примеры правильного и неправильного именования:

ПравильноНеправильно
OrderPurchase
OrderItemOrderLine, OrderRow
addItem()addProduct()
ProductSnapshotProductInfo

Рекомендуется добавить ArchUnit-тест:

@ArchTest
static final ArchRule no_forbidden_names = noClasses()
    .should().haveSimpleNameContaining("Purchase")
    .orShould().haveSimpleNameContaining("Cart");

12. Конфигурация: connection pool, кеш, индексы, health checks, метрики

НФТ транслируются в конфигурацию и код:

  • Connection pool: HikariCP maximumPoolSize=20, connectionTimeout=5000ms
  • Кеш: @Cacheable("orders") с TTL 5 мин, @CacheEvict при обновлении
  • Индексы: CREATE INDEX idx_orders_status_customer ON orders(status, customer_id, created_at)
  • Health check: OrderServiceHealthIndicator -- проверка БД, Kafka, внешних сервисов
  • Аудит: @EntityListeners(AuditingEntityListener.class), @CreatedBy, @LastModifiedBy
  • Метрики: Micrometer Timer на endpoint, Counter для ошибок, Gauge для outbox queue

Пример конфигурации в application.yml:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 5000
  cache:
    type: redis
    redis:
      time-to-live: 300000  # 5 min

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  health:
    kafka:
      enabled: true
    db:
      enabled: true

Интеграции с внешними системами конфигурируются через адаптеры:

  • Adapter: CatalogServiceAdapter implements CatalogPort
  • HTTP client: WebClient с timeout, retry, circuit breaker
  • Circuit breaker: @CircuitBreaker(name = "catalog", fallbackMethod = "getCachedProducts")
  • Kafka producer: KafkaTemplate<String, OrderEvent> с acks=all, retries=3
  • Конфигурация: URL, timeout, retry -- в application.yml, не в коде

Приемочные тесты из спецификации

Каждый Acceptance Criteria -> автоматический integration test:

  • AC-1 -> @SpringBootTest: POST /api/v1/orders с валидными данными -> 201 Created, заказ в БД, событие в outbox
  • AC-2 -> тест: POST с пустым items -> 422, code: ORDER_EMPTY, в БД ничего
  • AC-8 -> тест: GET /api/v1/orders/{чужойId} -> 404

Тесты приемочные -- Testcontainers, реальная БД, вся цепочка.