Implementing Business Workflows
Database Transactions
আপনি একটি 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 RuntimeException ও Error-এর জন্য হয়।
আমাদের জন্য সবচেয়ে গুরুত্বপূর্ণ অর্থ:
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
@Transactionaldeliberately. 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.@TransactionalusesREQUIREDpropagation by default.REQUIREDstarts 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.
RuntimeExceptionandErrortrigger 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_NEWis 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()orflush()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.