In the order-service design, the most useful transaction question is what must remain true after a request finishes. An accepted order should have a corresponding stock reservation. A repeated request should not reserve the same stock twice. If another service needs to hear about the order, the notification should remain recoverable after an application restart.
@Transactional helps implement part of that contract. It does not automatically enforce inventory rules, recognize duplicate requests, or reverse an external payment.
I use order creation here to connect those responsibilities: where the local transaction begins, how concurrent requests modify stock, and how committed changes reach other systems. The examples assume Spring’s default proxy mode, MySQL InnoDB, and one local transaction manager. Repository methods are illustrative; dependency injection and mapping details are omitted.
Define the business boundary before adding the annotation
For this example, creating an order reserves stock while the customer completes payment. The stock row tracks available and reserved quantities, while a reservation record identifies which order owns each reservation.
Creation reduces available stock and increases reserved stock. Payment confirmation later confirms the reservation for fulfillment. Cancellation releases an active reservation and returns its quantity to available stock.
This distinction makes the first transaction’s purpose clear: the order, its reservation, and the stock counters must agree. An order awaiting payment without reserved stock is incomplete. Reserved stock without an identifiable order is equally problematic.
A notification has different requirements. If an order confirmation email fails, the order can remain valid while delivery is retried. I therefore keep the email operation outside the database transaction, while preserving the intent to notify downstream systems inside it.
Spring must actually apply the declared transaction boundary. In default proxy mode, a call such as this.saveOrder() bypasses the transaction interceptor. If the caller has no transaction, an annotation on saveOrder() does not start one. If a transaction already exists, the database work may still participate in it, but the internal method’s propagation settings are not reapplied.
For this design, I place @Transactional on the public order-creation method invoked through a Spring-managed bean. The repositories participate in that transaction rather than opening independent transactions.
Failure handling belongs to the same boundary. Without customized rollback rules, Spring rolls back for RuntimeException and Error; checked exceptions need an appropriate rule. Applications can also change the global default in newer Spring versions. Catching an exception and returning normally may allow a commit, so an operation that must fail should communicate that failure explicitly.
There is an important exception to “caught means committed”: an inner participant may already have marked the shared transaction rollback-only. Catching its exception does not restore the transaction, and completion can produce UnexpectedRollbackException. Similarly, REQUIRES_NEW commits independently and may require another connection. I would use it only when the separately committed fact remains valid even if order creation fails.
Put the inventory decision into the update
A transaction can commit all its statements successfully and still violate the inventory rule.
Suppose two requests read one available unit. Each calculates zero in Java, writes that fixed value back, and saves an order. The writes can execute one after another without preventing the mistake.

Both ordinary reads happen before either update. Each request writes a value calculated from its earlier read, without a stock predicate or version check.
The problematic update is effectively:
UPDATE product_stock
SET available = :valueCalculatedEarlier
WHERE sku_id = :skuId;
Available stock ends at zero, so a check for negative values would miss the problem. The system has nevertheless promised the same unit to two orders.
For a simple reservation, I put the availability condition into the modification itself:
UPDATE product_stock
SET available = available - :quantity,
reserved = reserved + :quantity
WHERE sku_id = :skuId
AND available >= :quantity;
This assumes a unique sku_id, non-null counters, and a validated positive quantity. The application must check that exactly one row changed. Zero affected rows means the reservation did not succeed; it is not an exception that a transaction manager will necessarily detect for us.
Concurrent updates to the same row are coordinated by InnoDB. The condition participates in the update, so the second request cannot simply reuse an earlier application-level decision that stock was sufficient.
For more complex decisions, a version predicate or SELECT ... FOR UPDATE may be appropriate. Ordinary consistent reads do not provide the same locking behavior as locking reads. A locking read must also run within the transaction whose later work it protects.
I would choose among these approaches based on the rule being enforced. A single-row availability check fits conditional SQL well. A calculation involving several mutable fields may justify a locked read or optimistic version check. Either way, remote calls should not extend the time spent holding inventory locks.
The inventory SQL solves competition between different orders. A repeated request for the same order needs another mechanism.
Make duplicate requests resolve to one order
A customer can submit checkout twice. A gateway can retry after a timeout. The original transaction can also commit successfully while its response is lost.
For those cases, I give each logical order-creation attempt a stable request key. The order table enforces a unique constraint such as (customer_id, request_key), with the customer identity taken from the authenticated context.
The key is associated with the normalized business input. Reusing it for another SKU, quantity, or accepted quote should produce a conflict rather than silently return an unrelated order. A legitimate new purchase uses a new key.
An initial lookup can avoid unnecessary work, but it cannot enforce uniqueness: two concurrent requests can both find nothing. The database constraint closes that race.
The transaction then has a compact shape:
@Transactional
public OrderReceipt createOrder(CreateOrderCommand command) {
validate(command);
// Enforces UNIQUE(customer_id, request_key).
// Stores the normalized request identity for duplicate checks.
Order order = orderRepository.insertAwaitingPayment(command);
int changed = stockRepository.reserve(
command.skuId(),
command.quantity()
);
if (changed != 1) {
throw new StockUnavailableException(command.skuId());
}
reservationRepository.insertActive(
order.id(),
command.skuId(),
command.quantity()
);
outboxRepository.insert(
OrderCreatedEvent.from(order)
);
return OrderReceipt.from(order);
}
Here, StockUnavailableException is an unchecked exception. All four repositories use the same transaction-managed data source, and the insert operations report failures through exceptions. Returning from the method body is followed by transaction completion before the proxy returns successfully to its caller.
The order is inserted before the stock update so that the request key is claimed early. A later stock or Outbox failure rolls that insert back with the rest of the transaction.
Duplicate handling belongs outside the failed transaction. A nontransactional caller can invoke this service through its proxy, recognize a violation of the specific request-key constraint, and read the original committed order after rollback. It must verify that the stored request matches. Other constraint violations must not be treated as successful duplicates.
That separation avoids trying to continue inside a transaction that may already be unusable. It also gives response-loss recovery a clear path: repeat the same request identity and retrieve the existing result.
The reservation’s later lifecycle needs equivalent protection. Cancellation should conditionally transition an active reservation to released and restore stock in the same local transaction. A repeated cancellation that changes no reservation must not increment available stock again.
Payment confirmation and cancellation must also coordinate their state transitions. If payment is confirmed after a reservation has already expired, fulfillment requires an explicit recovery decision. The creation transaction cannot settle that later business conflict.
Keep external work recoverable without holding database locks
Calling payment, email, or a message broker while holding the inventory transaction open introduces two problems.
First, database connections and locks remain occupied while the application waits on the network. Second, rolling back MySQL cannot undo an external action that has already completed.
Moving publication after commit shortens the transaction, but leaves a failure window: the database commits, then the process exits before publishing the event.
The Outbox record in the creation method addresses that window. It commits with the order and reservation, leaving a durable event for a separate relay to publish.

The transaction preserves the event. The relay handles delivery, while the consumer protects its own business update against duplicates.
This guarantee depends on actual transaction participation. Writing three repository calls inside one Java method is insufficient if one repository uses another database or an independent transaction.
The relay reads committed events and publishes them with stable event IDs. With multiple relay workers, work claiming needs coordination and recovery when a worker disappears. Publishing should happen outside the order-creation transaction, with bounded attempts and backoff.
An uncertain acknowledgement must remain recoverable. The broker may have accepted an event even though the relay did not receive confirmation. A relay may also receive confirmation and crash before recording delivery. Both cases can produce another publication of the same event.
For a consumer updating its own database, a durable deduplication record and the business modification should commit together. Acknowledgement follows that commit. If the consumer crashes afterward but before acknowledging, redelivery finds the existing record and avoids repeating the effect.
An external action needs more than a local deduplication table. A payment integration still requires the provider’s idempotency contract, status queries, and reconciliation. An Outbox event preserves an intent or fact; it does not make an external payment atomic with MySQL.
Operationally, I would watch the age of the oldest undelivered event as well as the queue size. A small number of permanently failing events can matter more than a large queue that is draining normally. Failed events need a visible recovery path rather than silent deletion after a retry limit.
Run a two-request reservation test
Use a staging MySQL 8 InnoDB database with one row such as (sku_id=1001, available=1, reserved=0). The SQL below is a concurrency exercise; adapt the table and column names to the application schema and record the affected-row counts from both sessions.
-- Session A
START TRANSACTION;
UPDATE product_stock
SET available = available - 1, reserved = reserved + 1
WHERE sku_id = 1001 AND available >= 1;
-- expect 1 row; keep this transaction open
-- Session B, started before Session A commits
START TRANSACTION;
UPDATE product_stock
SET available = available - 1, reserved = reserved + 1
WHERE sku_id = 1001 AND available >= 1;
-- expect the statement to wait, then affect 0 rows
COMMIT;
-- Session A
COMMIT;
After both sessions finish, query the row in a fresh connection. The expected invariant is available=0, reserved=1, and one accepted reservation. Repeat the test with the same request key in two application requests, then with the same key and a different SKU or quantity; the first pair should resolve to one order and the second should return a conflict without another stock change.
| Check | Evidence to keep |
|---|---|
| Conditional stock update | Affected-row count from each session |
| Duplicate request | Unique-constraint result and returned order ID |
| Rollback path | Fresh-connection values for order, reservation, and stock |
| Outbox recovery | Committed event ID after publication is interrupted |
Verify the state left behind, then investigate the waiting
A successful response from one request does not establish that the transaction design is correct. I would verify the boundaries by deliberately interrupting them and checking committed database state.
The following table describes expected results, not measured production outcomes:
| Scenario | Expected result |
|---|---|
| Two different orders compete for one available unit | One accepted order and one stock-unavailable result; available stock becomes zero and reserved stock becomes one |
| Two concurrent requests use the same request key and input | One order and one reservation; both requests ultimately resolve to that order |
| The same key is reused with different business input | A conflict, with no additional stock reserved |
| Reservation persistence fails after the stock update | The order and stock changes roll back |
| Outbox insertion fails | No order, reservation, or stock change commits |
| The application exits after commit but before publication | The committed Outbox event remains available for delivery |
| The relay crashes after broker acceptance but before recording delivery | Duplicate publication may occur, but the consumer’s business effect is not repeated |
| Cancellation is delivered twice | The reservation is released and stock restored only once |
For the concurrency test, I would coordinate requests so that they actually overlap. Sending two requests sequentially cannot reproduce the old-value race shown earlier.
For rollback tests, the business entry point should be invoked through the real Spring bean. A test method that already owns a transaction can conceal a missing service boundary. Final-state checks should run after the operation has completed, using a fresh transaction or connection.
If the result is wrong, I first inspect transaction participation, exception handling, executed SQL, and affected-row counts. If the result is correct but slow, I separate connection-pool waiting, lock waiting, SQL execution, and remote latency.
Useful database starting points include:
SHOW ENGINE INNODB STATUS;
EXPLAIN
UPDATE product_stock
SET available = available - 1,
reserved = reserved + 1
WHERE sku_id = 1001
AND available >= 1;
The execution plan helps assess index use; it does not reveal every runtime lock wait. Live blocking relationships require the relevant database monitoring information, such as Performance Schema lock-wait data.
For orders containing multiple SKUs, consistent access order can reduce deadlock opportunities. A deadlock victim should retry the complete local transaction after the failed attempt ends, with bounded retries and backoff. Retrying only the final statement can reuse decisions made in a transaction that has already been rolled back.
Increasing the connection pool is not my first response to this kind of slowdown. More connections can mean more transactions competing for the same stock rows. Shorter transactions, appropriate indexes, and controlled retries address the sources of waiting more directly.
For this order flow, the responsibilities are now explicit. The transaction keeps the order, reservation, and event together. Conditional SQL protects available stock. The request-key constraint prevents duplicate creation. Reservation transitions prevent duplicate release, and the Outbox leaves downstream work recoverable.
Those are the properties I would use to review the implementation—and the properties the tests should prove.
For lease expiry and stale-worker protection, see Distributed Locks in Java: Redis, Redlock, ZooKeeper, and Fencing. For downstream event deduplication, compare Reliable Messaging in Java: Outbox, Idempotent Rewards, and Recovery.
