Implementing Business Workflows

Database Transactions

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

আমাদের CreateOrderUseCase এখন কয়েকটি গুরুত্বপূর্ণ database mutation coordinate করে:

consume Inventory A
consume Inventory B
consume Inventory C

persist Order
persist OrderItems

প্রতিটি operation individually correct হলেও একটি বড় প্রশ্ন এখনও বাকি:

মাঝখানে কোনো step fail করলে কী হবে?

ধরুন:

Product A Inventory
→ successfully decreased

Product B Inventory
→ successfully decreased

Order insert
→ fails

যদি প্রথম দুই Inventory update ইতিমধ্যে permanent হয়ে যায়, তাহলে আমরা পাব:

Inventory decreased

but

no Order exists

এটি system corruption।

Create Order-এর business meaning হলো:

Inventory consumption
+
Order creation
+
OrderItems persistence

একটি single operation।

তাই database-এর perspective থেকেও এগুলো হতে হবে:

all commit

অথবা:

all rollback

এই lesson-এর goal:

Create Order-কে একটি real transactional unit of work হিসেবে implement করা এবং বোঝা কেন transaction boundary Repository method নয়, পুরো UseCase-কে follow করবে।


What Is a Database Transaction?

একটি transaction হলো database operations-এর এমন একটি group যাকে আমরা এক logical unit হিসেবে treat করি।

Conceptually:

BEGIN

UPDATE inventory ...

UPDATE inventory ...

INSERT INTO orders ...

INSERT INTO order_items ...

COMMIT

সবকিছু successful হলে:

COMMIT

Database changes permanent হয়।

কোনো required operation fail করলে:

ROLLBACK

এবং transaction-এর changes আর persist হয় না।


Our Business Transaction

Create Order-এর জন্য transaction boundary:

CreateOrderUseCase.execute()

এর শুরু থেকে successful completion পর্যন্ত।

Architecture:

CreateOrderHandler
        ↓
CreateOrderUseCase
        │
        │ one transaction
        │
        ├── ProductRepository
        ├── InventoryRepository
        └── OrderRepository
                ↓
            PostgreSQL

UseCase জানে:

which database operations
belong to one business operation

তাই UseCase transaction boundary-এর natural owner।


Why Repository Transactions Are Not Enough

Suppose:

InventoryRepository.consumeIfAvailable()

নিজের transaction চালায়।

Then:

OrderRepository.save()

নিজের transaction চালায়।

Flow could become:

Transaction 1
consume Inventory A
COMMIT

Transaction 2
consume Inventory B
COMMIT

Transaction 3
save Order
FAIL

Result:

Inventory consumed

no Order

প্রতিটি Repository technically নিজের কাজ correctly করেছে।

কিন্তু application operation incorrect হয়েছে।

কারণ transaction boundary ছিল:

database method

এর around।

যেখানে হওয়া উচিত ছিল:

business operation

এর around।


Put the Transaction Around the UseCase

Our final direction:

@Component
public class CreateOrderUseCase {

    // dependencies...

    @Transactional
    public Order execute(
            CreateOrderCommand command
    ) {
        // complete Create Order workflow
    }
}

Spring-এর current default @Transactional semantics অনুযায়ী propagation হলো REQUIRED, isolation হলো underlying transaction system-এর default, transaction read-write, এবং default rollback unchecked RuntimeExceptionError-এর জন্য হয়।

আমাদের জন্য সবচেয়ে গুরুত্বপূর্ণ অর্থ:

execute() starts
    ↓
transaction begins
    ↓
all Repository operations participate
    ↓
method succeeds
    ↓
commit

অথবা:

method throws rollback-triggering exception
    ↓
rollback

Propagation.REQUIRED

Default:

@Transactional

essentially uses:

Propagation.REQUIRED

যদি current call-এর জন্য transaction না থাকে:

new transaction

create হয়।

যদি already একটি compatible outer transaction থাকে:

existing transaction

join করা হয়। Spring-এর documentation এটাকে common unit-of-work call stack-এর জন্য normal default হিসেবে describe করে।

আমাদের Create Order-এর জন্য explicit:

@Transactional(
        propagation =
                Propagation.REQUIRED
)

লেখার প্রয়োজন নেই।

Default যথেষ্ট।

Prefer:

@Transactional

যতক্ষণ না different propagation-এর real requirement আসে।


One Physical Transaction

Conceptually:

CreateOrderUseCase.execute()

    ProductRepository.findById()

    InventoryRepository.consumeIfAvailable()

    InventoryRepository.consumeIfAvailable()

    OrderRepository.save()

সব operation same transaction context-এর মধ্যে execute করবে, assuming তারা same datasource/transaction manager-এর মাধ্যমে participate করছে।

Spring Data JPA-ও multiple repository operations-এর unit of work-এর transaction boundary outer application layer-এ define করার recommendation দেয়।

আমাদের current architecture-এ:

one application

one PostgreSQL datasource

তাই এটি straightforward local transaction।


Final CreateOrderUseCase

এখন workflow-টি আরও concrete করা যাক।

package io.liveklass.ordermanagement.order.usecase;

import io.liveklass.ordermanagement.inventory.repository.InventoryRepository;
import io.liveklass.ordermanagement.order.domain.Order;
import io.liveklass.ordermanagement.order.domain.OrderId;
import io.liveklass.ordermanagement.order.domain.OrderItem;
import io.liveklass.ordermanagement.order.repository.OrderRepository;
import io.liveklass.ordermanagement.product.domain.Product;
import io.liveklass.ordermanagement.product.repository.ProductRepository;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Component
public class CreateOrderUseCase {

    private final ProductRepository
            productRepository;

    private final InventoryRepository
            inventoryRepository;

    private final OrderRepository
            orderRepository;

    private final Clock clock;

    public CreateOrderUseCase(
            ProductRepository productRepository,
            InventoryRepository inventoryRepository,
            OrderRepository orderRepository,
            Clock clock
    ) {
        this.productRepository =
                productRepository;

        this.inventoryRepository =
                inventoryRepository;

        this.orderRepository =
                orderRepository;

        this.clock =
                clock;
    }

    @Transactional
    public Order execute(
            CreateOrderCommand command
    ) {
        ensureNoDuplicateProducts(
                command.items()
        );

        List<OrderItem> orderItems =
                buildOrderItems(
                        command.items()
                );

        consumeInventory(
                command.items()
        );

        Order order =
                Order.create(
                        OrderId.newId(),
                        command.customerId(),
                        orderItems,
                        Instant.now(clock)
                );

        orderRepository.save(
                order
        );

        return order;
    }

    private List<OrderItem> buildOrderItems(
            List<CreateOrderItemCommand> items
    ) {
        List<OrderItem> orderItems =
                new ArrayList<>();

        for (
                CreateOrderItemCommand item :
                items
        ) {
            Product product =
                    productRepository
                            .findById(
                                    item.productId()
                            )
                            .orElseThrow(
                                    ProductNotFoundException::new
                            );

            if (!product.active()) {
                throw new ProductNotOrderableException(
                        product.id()
                );
            }

            orderItems.add(
                    new OrderItem(
                            product.id(),
                            item.quantity(),
                            product.priceCents()
                    )
            );
        }

        return List.copyOf(
                orderItems
        );
    }

    private void consumeInventory(
            List<CreateOrderItemCommand> items
    ) {
        List<CreateOrderItemCommand>
                sortedItems =
                items.stream()
                        .sorted(
                                Comparator.comparing(
                                        item ->
                                                item.productId()
                                                        .value()
                                )
                        )
                        .toList();

        for (
                CreateOrderItemCommand item :
                sortedItems
        ) {
            boolean consumed =
                    inventoryRepository
                            .consumeIfAvailable(
                                    item.productId(),
                                    item.quantity()
                            );

            if (!consumed) {
                throw new InsufficientInventoryException(
                        item.productId()
                );
            }
        }
    }

    private void ensureNoDuplicateProducts(
            List<CreateOrderItemCommand> items
    ) {
        Set<?> productIds =
                new HashSet<>();

        for (
                CreateOrderItemCommand item :
                items
        ) {
            if (
                    !productIds.add(
                            item.productId()
                    )
            ) {
                throw new DuplicateOrderProductException(
                        item.productId()
                );
            }
        }
    }
}

এই flow-তে:

Product validation

Inventory consumption

Order persistence

একই transaction-এর মধ্যে।


What Happens on Success?

Suppose:

Product A Inventory = 10

Product B Inventory = 8

Request:

A × 2
B × 3

Transaction begins।

Then:

A
10 → 8
B
8 → 5

Order inserted।

OrderItems inserted।

Everything succeeds।

Then:

COMMIT

Final state:

Product A Inventory = 8

Product B Inventory = 5

Order exists

OrderItems exist

This is a successful atomic operation।


What Happens When Second Inventory Fails?

Initial:

Product A Inventory = 10

Product B Inventory = 1

Request:

A × 2

B × 5

Transaction:

A consume
10 → 8

Then:

B consume
→ false

UseCase throws:

InsufficientInventoryException

Assuming this is a RuntimeException, Spring's default rollback rules mark the transaction for rollback.

Final state after rollback:

Product A Inventory = 10

Product B Inventory = 1

no Order

A's successful SQL update was part of the same transaction।

It never becomes permanent।


This Is Why Partial Mutation Is Safe

Earlier we were careful to:

validate all Products first

before Inventory mutations।

That still reduces unnecessary writes।

But once Inventory consumption starts, later Inventory failure is safe because:

all Inventory updates

are within:

same transaction

So:

A succeeds
B succeeds
C fails

causes:

A rollback

B rollback

C unchanged

Order not persisted

What If Order Persistence Fails?

Another scenario:

Inventory A
→ consumed

Inventory B
→ consumed

Then:

OrderRepository.save(order)
→ database exception

The transaction fails।

Expected final state:

Inventory A
→ restored by rollback

Inventory B
→ restored by rollback

Order
→ absent

OrderItems
→ absent

No manual compensation code is needed for local PostgreSQL changes।


Don't Manually Restore Inventory on Transaction Failure

Bad:

try {
    consumeInventory();
    orderRepository.save(order);
} catch (Exception exception) {
    restoreInventoryManually();
    throw exception;
}

For operations inside one PostgreSQL transaction, rollback already provides atomicity।

Manual compensation would add:

extra queries

more failure paths

double-restoration risk

Local transaction failure should use:

ROLLBACK

not application-written undo logic।


Compensation Is a Different Concept

Later, external Payment introduces a different situation:

Payment Provider
+
PostgreSQL

Those cannot simply share one local database transaction।

There compensation/idempotency/recovery may become relevant।

But:

Inventory
+
Order
+
OrderItems

are all local PostgreSQL state।

Use normal transaction atomicity।


Business Errors Should Roll Back

Our Create Order business failures include:

ProductNotFoundException

ProductNotOrderableException

DuplicateOrderProductException

InsufficientInventoryException

For a mutating workflow, once one of these is thrown after database mutations begin, we want rollback।

A simple approach is to model expected application failures as unchecked exceptions:

public final class
InsufficientInventoryException
        extends RuntimeException {
}

Then they align with Spring's default rollback rules.


Checked Exceptions Are Different

Suppose we instead wrote:

public final class
InsufficientInventoryException
        extends Exception {
}

This is a checked exception।

Spring's default @Transactional rollback behaviour does not automatically roll back for checked exceptions; the defaults can be customized using rollback rules such as rollbackFor.

For example:

@Transactional(
        rollbackFor =
                InsufficientInventoryException.class
)

could explicitly request rollback।

But introducing checked business exceptions here provides little value।

Our application errors can remain runtime exceptions।


Don't Memorize "Checked = Commit" as a Database Law

This behaviour is:

Spring default transaction policy

not a fundamental property of PostgreSQL transactions।

Spring lets us configure different rollback rules.

The engineering lesson is:

Know which failures mark your application transaction for rollback. Do not assume every thrown Java exception behaves identically.


Catching an Exception Can Change the Result

Consider:

@Transactional
public Order execute(...) {

    consumeInventory();

    try {
        orderRepository.save(order);
    } catch (RuntimeException exception) {
        log.warn(
                "Could not save order"
        );
    }

    return order;
}

This is dangerous।

The exception has been swallowed।

From the transactional method's perspective, execution may continue normally.

Now code is relying on subtle transaction state instead of making failure explicit।

For a required operation:

Order persistence failed

the UseCase should not pretend success।

Prefer:

let the failure propagate

or deliberately translate it while preserving rollback semantics।


Translate Specific Exceptions Carefully

Sometimes infrastructure exception translation is appropriate.

For example:

database constraint violation

might correspond to a known application condition।

But avoid:

catch (Exception exception) {
    throw new OrderCreationException();
}

unless OrderCreationException preserves:

correct failure meaning

rollback behaviour

and the original cause where appropriate।

Broad catch blocks make transactional code harder to reason about।


@Transactional Does Not Mean "No Exceptions"

The annotation does not make:

database failure impossible

deadlock impossible

constraint violation impossible

connection failure impossible

It gives us:

transaction lifecycle

commit/rollback coordination

Failures still need correct application handling।


Transaction Does Not Replace Business Validation

Bad thinking:

We'll just try all SQL and rollback if something breaks.

We still validate:

duplicate Product IDs

Product existence

Product active state

positive quantities

because those are meaningful application rules।

Transaction is not a replacement for:

domain design

or:

business validation

It protects the atomicity of persistent changes।


Transaction Does Not Replace Database Constraints

Likewise:

@Transactional

does not replace:

CHECK (available_quantity >= 0)
CHECK (price_cents >= 0)
CHECK (quantity > 0)
FOREIGN KEY
PRIMARY KEY

Different mechanisms solve different integrity problems।


Transaction Boundary Should Stay Small

Create Order transaction does:

database reads

conditional Inventory updates

Order persistence

These operations are directly necessary for the business operation।

We should not add unrelated work inside:

@Transactional

such as:

send email

upload image

call analytics API

sleep

perform slow remote HTTP call

Long transactions hold database resources longer and increase contention risk।


No Payment Provider Inside This Transaction

Later:

PayOrderUseCase

will call an external Payment provider।

Do not write:

@Transactional
public void payOrder(...) {

    Order order = ...;

    paymentService.charge(...);

    order.markPaid();

    orderRepository.save(order);
}

and assume:

Payment Provider

is part of PostgreSQL's rollback।

If provider charge succeeds and PostgreSQL later rolls back:

external payment

does not magically reverse।

Spring's local transaction context also does not propagate transaction atomicity across arbitrary remote service calls.

That problem belongs to the External Services module।


Repository Methods Join the Outer Transaction

When CreateOrderUseCase.execute() begins the transaction, Repository calls operate within that application unit of work rather than each defining an independent business transaction।

This is exactly why Spring Data recommends transaction boundaries around units of work spanning multiple repository calls.

Conceptually:

CreateOrderUseCase transaction
│
├── ProductRepository.findById()
│
├── InventoryRepository.consumeIfAvailable()
│
├── InventoryRepository.consumeIfAvailable()
│
└── OrderRepository.save()

Not:

four unrelated commits

Don't Use REQUIRES_NEW Here

Suppose someone changes Inventory consumption to:

@Transactional(
        propagation =
                Propagation.REQUIRES_NEW
)

Now Inventory operation can execute in a separate physical transaction from Create Order।

That would break the atomicity we want։

Example:

Inventory REQUIRES_NEW
→ commits

outer Order transaction
→ fails

Result:

Inventory consumed
no Order

Exactly the corruption we're trying to prevent।

So our mutation Repositories should participate in the outer transaction—not independently commit stock changes।


REQUIRED Is the Right Shape

For this workflow:

one business operation

needs:

one local physical transaction

REQUIRED naturally supports that arrangement by starting a transaction if none exists and participating in the existing outer one otherwise.

No exotic propagation setting is needed।


Avoid UseCase-to-UseCase Transaction Fragmentation

Bad:

CreateOrderUseCase
    ↓
ConsumeInventoryUseCase
        @Transactional
    ↓
SaveOrderUseCase
        @Transactional

Now we have multiple application operations pretending to be reusable substeps।

The actual operation is:

Create Order

So:

CreateOrderUseCase

should own the transaction।

Internal steps stay:

private methods

Domain methods

Repository operations

as appropriate।


Important Spring Detail: Transactional Proxies

Spring's declarative transaction support commonly works through AOP proxies that intercept method invocations and apply transaction behaviour around them.

That means transaction semantics are not simply:

Java sees an annotation, therefore magic happens.

There is infrastructure around the Spring-managed Bean invocation।

This matters when designing UseCases।


Avoid Transactional Self-Invocation Tricks

Suppose:

@Component
public class OrderUseCase {

    public void execute() {
        doTransactionalWork();
    }

    @Transactional
    public void doTransactionalWork() {
        ...
    }
}

and execute() directly calls:

this.doTransactionalWork();

With the normal proxy-based model, relying on that internal self-call to establish a new transactional boundary is a common mistake because it does not pass through the external proxy in the usual way. Spring's transaction implementation is proxy-based by default.

Our architecture avoids the problem naturally:

@Transactional
public Order execute(...)

The externally invoked UseCase entry point itself owns the transaction।


Handler Calls the Transactional Bean

Flow:

ProductHandler / OrderHandler
    ↓
Spring-managed CreateOrderUseCase proxy
    ↓
transaction begins
    ↓
CreateOrderUseCase.execute()

That's a straightforward arrangement।

We do not need:

ApplicationContext.getBean(...)

self injection

transaction utility helper

to trigger transactional behaviour।


Domain Objects Do Not Use @Transactional

Never:

public class Order {

    @Transactional
    public void cancel() {
        ...
    }
}

Domain objects are not responsible for database transaction boundaries।

Order.cancel() only decides:

is this state transition valid?

CancelOrderUseCase later decides:

cancel Order
+
restore Inventory

must be atomic।


Handler Does Not Use @Transactional

Avoid:

@PostMapping
@Transactional
public ResponseEntity<?> createOrder(...) {
    ...
}

The HTTP adapter should not own database business atomicity।

Otherwise:

REST

becomes the place transaction semantics live।

If another adapter later invokes the same UseCase:

scheduled job

message handler

internal operation

we want the business operation's transaction behaviour to stay intact।

So:

UseCase

owns it।


Persistence Adapter Does Not Decide the Whole Transaction

A Repository implementation may require transactional infrastructure for individual JPA mechanics, but it does not define the complete Create Order unit of work।

The higher-level transaction must remain:

CreateOrderUseCase

because only it knows:

Inventory updates
+
Order persistence

belong together।


Isolation Level

Default Spring @Transactional uses:

ISOLATION_DEFAULT

which delegates to the underlying transaction system's default isolation behaviour.

We do not override it for Create Order merely because concurrency exists।

Our critical Inventory invariant is already implemented using:

UPDATE inventory
SET available_quantity =
        available_quantity - :quantity
WHERE product_id = :productId
  AND available_quantity >= :quantity

That specific persistence operation handles competing stock consumption correctly without us globally increasing transaction isolation।


Don't Set SERIALIZABLE Without a Reason

We could write:

@Transactional(
        isolation =
                Isolation.SERIALIZABLE
)

but that changes concurrency behaviour for the whole operation and can introduce additional transaction retry requirements।

Our current design does not need it।

Use the narrowest mechanism that correctly protects the invariant।


Read-Only Transactions Are Different

For queries such as:

ListOrderableProductsUseCase

we used:

@Transactional(
        readOnly = true
)

Spring Data documents readOnly primarily as a hint that can propagate to underlying infrastructure; it should not be treated as a universal enforcement mechanism that makes writes impossible in every environment.

For Create Order:

readOnly = false

is required, and that is already the default։

So:

@Transactional

is enough।


No Transaction Around Multiple HTTP Requests

A database transaction lives around:

one application operation

not a whole user journey।

For example:

POST /orders

is one transaction।

Later:

POST /orders/{id}/pay

is another operation।

We do not keep a database transaction open between them।

An Order may remain:

UNPAID

for some time between HTTP requests।

That is normal persisted business state।


Transaction Is Not User Session

Do not confuse:

database transaction

with:

Customer session

or:

Order lifecycle

The database transaction might last milliseconds।

The Order itself may exist for minutes, hours, or longer।

Different concepts।


Flush vs Commit

With JPA, calling:

repository.save(...)

does not necessarily mean:

database transaction committed immediately

Persistence work may be synchronized/flushed as part of the transaction lifecycle।

The key architectural point is:

save() is not our business commit boundary.

Our commit boundary is successful completion of the transactional UseCase।

This is another reason not to reason about application atomicity in terms of individual save() calls։


Don't Call flush() After Every Step

Bad:

consume A
flush

consume B
flush

save Order
flush

flush() may force SQL synchronization, but it does not turn those operations into independent successful business commits within the same surrounding transaction।

Using it everywhere:

adds database work

couples application code to persistence behaviour

without solving a real problem।

Use explicit flush only when you genuinely need database feedback at a particular point।


Rollback Can Undo Already-Executed SQL

This is an important mental model।

Suppose SQL has physically executed:

UPDATE inventory
SET available_quantity = 8
...

within the transaction।

That does not mean:

8

has become a permanently committed business fact।

Until commit, the transaction may still roll back।

So:

SQL executed

is different from:

transaction committed

Example: Multi-Item Failure

Initial:

A = 10
B = 10
C = 1

Request:

A × 2
B × 3
C × 5

Inside transaction:

A
10 → 8
B
10 → 7
C conditional update
→ 0 rows

UseCase throws:

InsufficientInventoryException

Rollback।

Final:

A = 10

B = 10

C = 1

no Order

This is exactly the all-or-nothing guarantee we need।


Example: OrderItem Persistence Failure

Suppose:

Inventory updates
→ succeed

orders INSERT
→ succeeds

first order_items INSERT
→ succeeds

second order_items INSERT
→ violates unexpected constraint

Transaction fails।

Final state should be:

Inventory unchanged from before request

no Order

no OrderItems

The transaction boundary covers the aggregate persistence too।


Database Constraints Still Matter During the Transaction

OrderItem table:

PRIMARY KEY (
    order_id,
    product_id
)

protects duplicate lines।

Suppose an application bug somehow bypasses our duplicate validation and tries to persist duplicates।

Database rejects the invalid state।

That database error then causes the business transaction to fail rather than leaving a partial Order।

Multiple layers cooperate।


Error Translation Happens Outside the Domain

If:

InsufficientInventoryException

escapes the UseCase:

transaction rolls back

then HTTP boundary maps it to:

409
INSUFFICIENT_INVENTORY

Transaction concern and HTTP concern remain separate।

Flow:

UseCase throws application error
    ↓
transaction interceptor rolls back
    ↓
ControllerAdvice maps error
    ↓
Problem response

The Client Should Not See "Rollback"

Customer does not need:

{
  "error": "Transaction rolled back"
}

That's implementation detail।

Customer needs application meaning:

{
  "code": "INSUFFICIENT_INVENTORY",
  ...
}

Transaction makes the failure safe।

It does not define the public error language।


Testing Transactions Requires Integration Tests

A Mockito unit test cannot prove:

PostgreSQL rolled back

It can verify:

UseCase throws

but actual atomic persistence needs:

PostgreSQL

real transaction manager

real Repository adapters

So this behaviour deserves integration tests using Testcontainers।


The Critical Rollback Test

Setup:

Product A
Inventory = 10

Product B
Inventory = 1

Request:

A × 2

B × 5

Execute:

CreateOrderUseCase

Expected:

INSUFFICIENT_INVENTORY

Then use a fresh persistence context/transaction to query database।

Assert:

A Inventory = 10

B Inventory = 1

orders count unchanged

order_items count unchanged

This proves actual rollback behaviour।


Why Reload After Failure?

Do not verify only an in-memory object that existed before rollback।

We care about:

committed PostgreSQL state

So test should query again through a clean boundary after the failed transaction։

This prevents test assertions from accidentally reading stale persistence-context state।


Successful Commit Test

Setup:

A Inventory = 10

B Inventory = 10

Request:

A × 2

B × 3

After successful UseCase completion:

A = 8

B = 7

and:

Order exists

2 OrderItems exist

Order status = UNPAID

correct CustomerId

correct unitPriceCents

This tests the positive transaction path।


Simulate Order Persistence Failure

For a focused application test, we can use a test-specific Repository implementation that deliberately throws after Inventory consumption:

throw new RuntimeException(
        "Simulated persistence failure"
);

Then verify the surrounding real transaction rolls back database Inventory changes।

However, when possible, tests should avoid relying on artificial database corruption tricks simply to force failures।

The goal is:

prove outer transaction owns
all local mutations

Don't Put @Transactional on the Test and Hide the Behaviour

A common integration-test pattern is making the entire test:

@Transactional

and automatically rolling it back afterward।

That can sometimes hide the exact transaction boundary you're trying to test।

For transaction-behaviour tests, be deliberate:

invoke production transactional UseCase

let that transaction finish/fail

query committed state separately

We want to test the application's boundary, not only the test framework's transaction।


Create Product Transaction Is Simpler

Earlier:

CreateProductUseCase

also used:

@Transactional

but it involved essentially one aggregate persistence operation।

Create Order demonstrates why the pattern matters much more:

multiple repositories

multiple rows

multiple capabilities

must commit atomically।


Cancel Order Will Use the Same Principle

Later cancellation requires:

Order
UNPAID → CANCELLED

plus:

restore Inventory

Those also belong in one transaction।

Otherwise:

Order cancelled
but stock not restored

or:

stock restored
but Order still UNPAID

could occur।

So:

@Transactional
public CancelOrderResult execute(...)

will be the natural shape।


Payment Will Not Use the Same Simple Model

Local cancellation:

Order
+
Inventory
+
PostgreSQL

→ one transaction।

Payment:

PostgreSQL
+
external Payment Provider

→ cannot be made atomic merely by extending a local @Transactional method around both systems. Spring's local declarative transaction context does not magically make arbitrary remote calls participate in the same atomic resource transaction.

That distinction is fundamental।


Transaction Responsibility Map

Handler

HTTP request

authenticated identity

command mapping

response mapping

No transaction ownership।


UseCase

business operation

cross-Repository coordination

transaction boundary

This is where:

@Transactional

usually belongs for our mutating workflows।


Domain

local invariants

state transitions

calculations

No Spring transaction APIs।


Repository

query persistence

save persistence

atomic Inventory SQL

Participates in outer transaction।


PostgreSQL

actual atomic commit / rollback

row concurrency

constraints

durability

This is where persistent transactional guarantees ultimately happen।


Common Mistake 1 — Transaction Per Repository

This breaks multi-Repository business atomicity।


Common Mistake 2 — @Transactional on Handler

Transaction belongs to application operation, not HTTP transport।


Common Mistake 3 — @Transactional on Domain Entity

Domain should not know Spring/database transaction infrastructure।


Common Mistake 4 — REQUIRES_NEW on Inventory Consumption

That can commit Inventory independently from the outer Order transaction।

Wrong for this workflow।


Common Mistake 5 — Swallowing Exceptions

Required persistence failures must propagate or explicitly mark failure; don't log and pretend the operation succeeded।


Common Mistake 6 — Assuming Every Exception Automatically Rolls Back

Spring's default rollback semantics distinguish unchecked and checked exceptions, unless configured otherwise.

Know your error model।


Common Mistake 7 — Manual Undo Inside a Local Transaction

Let PostgreSQL rollback local transactional changes।

Do not recreate transaction semantics in application code।


Common Mistake 8 — Remote HTTP Call Inside Long Transaction

Database transaction cannot roll back an already-successful unrelated remote side effect।


Common Mistake 9 — flush() as Commit

SQL synchronization is not the same as successful business commit।


Common Mistake 10 — Transaction as Validation

Transactions protect atomicity; they do not replace Domain, UseCase, or database validation।


Common Mistake 11 — Self-Invocation Transaction Tricks

Spring declarative transaction support commonly relies on proxies. Keep the externally invoked UseCase method as the clear transactional entry point rather than depending on internal calls to annotated helper methods.


Common Mistake 12 — Increasing Isolation Without Need

Do not reach for:

SERIALIZABLE

before identifying the actual concurrency invariant।

Our Inventory conditional UPDATE already protects the critical stock-consumption decision।


Transaction Checklist

For a mutating UseCase, ask:

What business operation is this transaction protecting?

Which database changes must commit together?

Are all relevant Repository calls inside the same boundary?

Could any inner operation commit independently?

Which exceptions trigger rollback?

Are expected business failures represented consistently?

Are exceptions being swallowed?

Is any external network call unnecessarily inside the transaction?

Is transaction scope larger than the business operation?

Are database constraints still present?

Do integration tests prove rollback against real PostgreSQL?

Our Final Create Order Boundary

Canonical flow:

HTTP Request
    ↓
CreateOrderHandler
    ↓
CreateOrderUseCase.execute()
    ↓
BEGIN TRANSACTION
    ↓
validate Products
    ↓
capture priceCents
    ↓
consume Inventory A atomically
    ↓
consume Inventory B atomically
    ↓
consume Inventory C atomically
    ↓
create Order
    ↓
persist Order
    ↓
persist OrderItems
    ↓
COMMIT

Failure anywhere after transaction begins:

rollback required local database work

For expected runtime business failure such as:

INSUFFICIENT_INVENTORY

flow becomes:

consume A
    ↓
consume B
    ↓
C unavailable
    ↓
throw
    ↓
ROLLBACK
    ↓
A restored
B restored
no Order

Engineering Principle

The core principle:

A transaction boundary should follow a business unit of work. Repository methods are implementation steps; Create Order is the operation that must succeed or fail atomically.

Another:

Database operations may execute before a failure occurs, but until the transaction commits they are not final business state. Rollback is what lets multi-step local workflows fail safely.

And:

Use @Transactional deliberately. Understand propagation, rollback rules, and proxy-based invocation rather than treating the annotation as magic.


Summary

In this lesson, we established that:

  • Create Order is one transactional unit of work.
  • Inventory consumption, Order persistence, and OrderItem persistence must commit together.
  • Repository-level transactions alone do not guarantee business atomicity.
  • CreateOrderUseCase.execute() is the natural transaction boundary.
  • @Transactional uses REQUIRED propagation by default.
  • REQUIRED starts a transaction when needed or participates in an existing outer transaction.
  • Spring Data recommends transaction boundaries around units of work spanning multiple Repository calls.
  • Successful UseCase completion commits the local transaction.
  • A rollback-triggering failure prevents partial Inventory/Order state from becoming committed.
  • RuntimeException and Error trigger rollback by default in Spring's standard transactional configuration.
  • Checked exceptions do not automatically receive the same default rollback treatment.
  • Our expected mutating business failures can remain runtime application exceptions.
  • We do not manually restore Inventory when the surrounding PostgreSQL transaction rolls back.
  • Manual compensation is different from local database rollback.
  • REQUIRES_NEW is inappropriate for Inventory consumption because it could commit stock independently of Order creation.
  • We do not increase transaction isolation without a concrete need.
  • The concurrency-safe conditional Inventory UPDATE remains our stock-consumption mechanism.
  • Transactions do not replace business validation or database constraints.
  • Exceptions should not be broadly swallowed inside required transactional workflows.
  • save() or flush() is not the business commit boundary.
  • Spring declarative transaction support commonly operates through AOP proxies, so the public UseCase entry point should be the clear transactional method.
  • Handler and Domain remain free of transaction-management responsibility.
  • Repository operations participate in the UseCase transaction.
  • Transaction integration tests should verify committed PostgreSQL state after both success and failure.
  • Test-level automatic rollback should not hide the production transaction boundary being tested.
  • Local Create Order and Cancel Order can use normal PostgreSQL transactions.
  • External Payment cannot be made atomic with PostgreSQL merely by wrapping a remote call in @Transactional.

Next lesson:

Rollback and Failure Scenarios

There we will deliberately break Create Order at different points—missing Product, inactive Product, first/second Inventory failure, persistence failure, constraint failure, and concurrent failure—and verify exactly what state is allowed to remain after each failure.