DDD-спецификация: гайд для разработчика
Реализация DDD на Java/Spring Boot: модель, commands, events, API.
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-1 | Order.confirm() | if (items.isEmpty()) throw new OrderEmptyException(); |
BR-2 | Конструктор OrderItem | if (quantity < 1 \|\| quantity > 99) throw new InvalidQuantityException(); |
BR-3 | Order.recalculateTotal() | Проверка после пересчета суммы |
BR-4 | Order.cancel() | if (!status.canTransitionTo(CANCELLED)) throw new OrderCancelForbiddenException(); |
BR-5 | Order.addItem() | findExistingItem(productId).ifPresent(item -> item.increaseQuantity(quantity)) |
BR-6 | Order.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/orders | POST | CreateOrder |
/api/v1/orders/{orderId}/confirm | PUT | ConfirmOrder |
/api/v1/orders/{orderId}/items | POST | AddItem |
/api/v1/orders/{orderId}/cancel | PUT | CancelOrder |
/api/v1/orders/{orderId}/ship | PUT | ShipOrder |
/api/v1/orders/{orderId}/deliver | PUT | DeliverOrder |
/api/v1/orders/{orderId}/refund | POST | InitiateRefund |
/api/v1/orders/{orderId} | GET | GetOrder |
/api/v1/orders | GET | SearchOrders |
Каждая строка каталога ошибок -> класс исключения + маппинг в 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-правила, запрещающего классы с именами из колонки "Не используем"
Примеры правильного и неправильного именования:
| Правильно | Неправильно |
|---|---|
Order | Purchase |
OrderItem | OrderLine, OrderRow |
addItem() | addProduct() |
ProductSnapshot | ProductInfo |
Рекомендуется добавить 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, реальная БД, вся цепочка.