Skip to content

Створення моделі простого транзакційного сценарію

Мета: Ознайомитися з патерном Transaction Script та його використанням у програмуванні. Закріпити навички роботи з базами даних, обробки транзакцій та забезпечення узгодженості даних у програмі.


Завдання

Реалізувати приклад проєкту на тему «Інтернет-магазин», що імплементує патерн Transaction Script, де кожна операція має виконуватися в транзакції.

Опис: Користувач формує кошик, оформлює замовлення, а система перевіряє наявність товару та проводить оплату.

Проєкт має включати:

  1. Створити Product із id, name, price, stockQuantity.
  2. Реалізувати AddToCart, Checkout, CancelOrder.
  3. Використати Transaction Script для обробки операцій.
  4. Реалізувати зменшення залишку товару після покупки.
  5. Вивести історію замовлень.

Теоретичні відомості

Транзакційний сценарій (Transaction script) — це патерн, який організовує бізнес-логіку у вигляді процедур, де кожна процедура обробляє один конкретний запит від презентаційного шару (користувача).

Фактично, це простий процедурний скрипт, який виконує дії послідовно: отримує дані, проводить обчислення та зберігає результат у базу даних.

Ключові характеристики:

  • Транзакційна поведінка: Це головна вимога. Сценарій має виконуватися повністю успішно або повністю скасувати зміни (rollback). Він ніколи не повинен залишати систему в частково зміненому (неузгодженому) стані.
  • Сфера застосування: ідеально підходить для допоміжних піддоменів (Supporting Subdomains), простих CRUD-операцій або ETL-процесів (вилучення-перетворення-завантаження), де логіка є лінійною і простою.

  • Недоліки: У складних доменах (Core subdomains) цей підхід призводить до дублювання коду та перетворення системи на "велику купу бруду" (Big Ballof Mud), оскільки бізнес-правила розмиваються між різними скриптами.

Це повна протилежність Доменної моделі (де логіка живе всередині об'єктів), яку ми обговорювали раніше.


Хід роботи

Як випливає з теоретичної частини, транзакційний сценарій є протилежністю доменної моделі щодо розташування бізнес-логіки. Тому сутності Product та Order виступають лише як проєкції (або контейнери даних) таблиць у базі даних.

@Data
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String name;
    private BigDecimal price;
    private int stock;
}
@Data
@Entity
@Table(name = "shopping_carts")
public class Cart {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @ManyToMany
    @JoinTable(
        name = "shopping_carts_products",
        joinColumns = @JoinColumn(name = "cart_id"),
        inverseJoinColumns = @JoinColumn(name = "product_id"))
    private Set<Product> products;
}
@Data
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @ManyToMany
    @JoinTable(
        name = "orders_products",
        joinColumns = @JoinColumn(name = "order_id"),
        inverseJoinColumns = @JoinColumn(name = "product_id"))
    private Set<Product> products;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

В даному випадку проєкт керується трьома таблицями.

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;
    private final CartRepository cartRepository;

    @Transactional
    @Override
    public Product addToCart(UUID productId) {
        // 1. Find product by ID
        Product product = productRepository
            .findById(productId)
            .orElseThrow(
                () -> new IllegalArgumentException("Product not found with id: " + productId));

        // 2. Check stock availability
        if (product.getStock() <= 0) {
        throw new IllegalStateException("Product out of stock: " + product.getName());
        }

        // 3. Decrease stock
        product.setStock(product.getStock() - 1);
        productRepository.save(product);

        // 4. Get or create cart (singleton pattern)
        Cart cart = cartRepository
            .findFirstByOrderByIdAsc()
            .orElseGet(
                () -> {
                Cart newCart = new Cart();
                newCart.setProducts(new HashSet<>());
                return cartRepository.save(newCart);
                });

        // 5. Add product to cart
        cart.getProducts().add(product);
        cartRepository.save(cart);

        return product;
    }
}
Реалізація сервісу замовлень
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final CartRepository cartRepository;

    @Transactional
    @Override
    public BigDecimal checkout() {
        // 1. Get current cart
        Cart cart = cartRepository
            .findFirstByOrderByIdAsc()
            .orElseThrow(() -> new IllegalStateException("Cart not found"));

        // 2. Check cart is not empty
        if (cart.getProducts() == null || cart.getProducts().isEmpty()) {
        throw new IllegalStateException("Cannot checkout with empty cart");
        }

        // 3. Create new order
        Order order = new Order();
        order.setProducts(new HashSet<>(cart.getProducts()));
        order.setStatus(OrderStatus.OPEN);

        // 4. Calculate total price
        BigDecimal totalPrice = cart.getProducts().stream()
            .map(Product::getPrice)
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        // 5. Clear cart
        cart.getProducts().clear();
        cartRepository.save(cart);

        // 6. Save order
        orderRepository.save(order);

        return totalPrice;
    }

    @Transactional
    @Override
    public void cancel() {
        // 1. Find the last open order
        Order order = ((Iterable<Order>) orderRepository.findAll())
            .iterator().hasNext()
                ? ((Iterable<Order>) orderRepository.findAll()).iterator().next()
                : null;

        if (order == null) {
        throw new IllegalStateException("No order found to cancel");
        }

        // 2. Check order status is OPEN
        if (order.getStatus() != OrderStatus.OPEN) {
        throw new IllegalStateException("Cannot cancel order with status: " + order.getStatus());
        }

        // 3. Restore stock for each product
        for (Product product : order.getProducts()) {
        Product dbProduct = productRepository
            .findById(product.getId())
            .orElseThrow(
                () -> new IllegalStateException(
                    "Product not found during cancel: " + product.getId()));

        dbProduct.setStock(dbProduct.getStock() + 1);
        productRepository.save(dbProduct);
        }

        // 4. Set order status to CLOSED
        order.setStatus(OrderStatus.CLOSED);
        orderRepository.save(order);
    }
}

Cервіси виконують бізнес-логіку, а не в агрегаті, що є протилежним підходу DDD. Кожен метод містить анотацію @Transactional, що дозволяє виконати операцію успішно або відкотити зміни у разі помилки.


Висновки

Ознайомлення з патерном Transaction Script закріпило навички роботи з базами даних, обробки транзакцій та забезпечення атомарності й узгодженості операцій і даних у програмі. Результатом став приклад проєкту, що демонструє патерн, реалізуючи операції в рамках транзакцій і розміщуючи бізнес-логіку у сервісах.