OrderService.java

package io.slayyyyer.orders.service;

import io.slayyyyer.orders.domain.IdempotencyKeys;
import io.slayyyyer.orders.domain.Order;
import io.slayyyyer.orders.domain.OrderLine;
import io.slayyyyer.orders.domain.OrderPricing;
import io.slayyyyer.orders.domain.OrderStatus;
import io.slayyyyer.orders.domain.Product;
import io.slayyyyer.orders.repository.CustomerRepository;
import io.slayyyyer.orders.repository.OrderPage;
import io.slayyyyer.orders.repository.OrderRepository;
import io.slayyyyer.orders.repository.OutboxRepository;
import io.slayyyyer.orders.repository.ProductRepository;
import java.time.Clock;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    static final int MAX_PAGE_SIZE = 100;

    private final OrderRepository orders;
    private final CustomerRepository customers;
    private final ProductRepository products;
    private final OutboxRepository outbox;
    private final Clock clock;

    public OrderService(
            OrderRepository orders,
            CustomerRepository customers,
            ProductRepository products,
            OutboxRepository outbox,
            Clock clock) {
        this.orders = orders;
        this.customers = customers;
        this.products = products;
        this.outbox = outbox;
        this.clock = clock;
    }

    /**
     * Creates an order, or returns the original one when the idempotency key has been
     * seen before. Prices always come from the database; the request only chooses
     * products and quantities.
     */
    @Transactional
    public CreatedOrder create(CreateOrderCommand command) {
        String key = IdempotencyKeys.requireValid(command.idempotencyKey());

        Optional<UUID> existingId = orders.findIdByIdempotencyKey(key);
        if (existingId.isPresent()) {
            return replay(existingId.get());
        }

        if (command.customerId() == null) {
            throw new IllegalArgumentException("customerId is required");
        }
        customers.findById(command.customerId())
                .orElseThrow(() -> new NotFoundException("customer", command.customerId()));

        if (command.items().isEmpty()) {
            throw new IllegalArgumentException("an order needs at least one item");
        }

        Map<UUID, Integer> quantities = mergeQuantities(command.items());
        Map<UUID, Product> priced = products.findAllByIds(quantities.keySet());
        for (UUID productId : quantities.keySet()) {
            if (!priced.containsKey(productId)) {
                throw new NotFoundException("product", productId);
            }
        }

        List<OrderLine> lines = new ArrayList<>(quantities.size());
        for (Map.Entry<UUID, Integer> entry : quantities.entrySet()) {
            Product product = priced.get(entry.getKey());
            lines.add(new OrderLine(
                    product.id(),
                    product.sku(),
                    product.name(),
                    entry.getValue(),
                    product.unitPriceCents()));
        }

        Order order = new Order(
                UUID.randomUUID(),
                command.customerId(),
                OrderStatus.PENDING,
                "USD",
                OrderPricing.total(lines),
                clock.instant(),
                lines);

        try {
            orders.insert(order, key);
        } catch (DuplicateKeyException race) {
            // Two concurrent requests carried the same key. The unique index won; answer
            // the loser with the winner's order rather than a 500.
            return orders.findIdByIdempotencyKey(key)
                    .map(this::replay)
                    .orElseThrow(() -> new ConflictException("concurrent order creation for this idempotency key"));
        }

        // Same transaction as the insert: no event without an order, no order without an event.
        outbox.append(order, "order.created");

        return new CreatedOrder(order, false);
    }

    @Transactional(readOnly = true)
    public Order require(UUID id) {
        return orders.findById(id).orElseThrow(() -> new NotFoundException("order", id));
    }

    @Transactional(readOnly = true)
    public OrderPage byCustomer(UUID customerId, OrderStatus status, int page, int size) {
        if (page < 0) {
            throw new IllegalArgumentException("page must not be negative");
        }
        if (size < 1 || size > MAX_PAGE_SIZE) {
            throw new IllegalArgumentException("size must be between 1 and " + MAX_PAGE_SIZE);
        }
        customers.findById(customerId)
                .orElseThrow(() -> new NotFoundException("customer", customerId));
        return orders.findByCustomer(customerId, status, page, size);
    }

    @Transactional
    public Order transition(UUID id, OrderStatus next) {
        Order current = require(id);
        if (!current.status().canTransitionTo(next)) {
            throw new ConflictException(
                    "cannot move order from " + current.status() + " to " + next);
        }
        orders.updateStatus(id, next);
        Order updated = require(id);
        outbox.append(updated, "order.status-changed");
        return updated;
    }

    private CreatedOrder replay(UUID id) {
        Order existing = orders.findById(id)
                .orElseThrow(() -> new NotFoundException("order", id));
        return new CreatedOrder(existing, true);
    }

    private Map<UUID, Integer> mergeQuantities(List<CreateOrderCommand.RequestedItem> items) {
        Map<UUID, Integer> quantities = new LinkedHashMap<>();
        Map<UUID, Integer> seen = new HashMap<>();
        for (CreateOrderCommand.RequestedItem item : items) {
            if (item == null || item.productId() == null) {
                throw new IllegalArgumentException("each item needs a productId");
            }
            if (item.quantity() <= 0) {
                throw new IllegalArgumentException("quantity must be positive");
            }
            if (seen.put(item.productId(), item.quantity()) != null) {
                quantities.merge(item.productId(), item.quantity(), Math::addExact);
            } else {
                quantities.put(item.productId(), item.quantity());
            }
        }
        return quantities;
    }
}