A customer orders two books. Your application reduces the stock, then saves the order. What happens if saving the order fails? Without a shared transaction, your shop can lose inventory without recording a sale. The customer sees an error, while your database keeps half the work.
Spring's @Transactional annotation helps define which database changes belong together. To use it confidently, you need to understand where the transaction starts, which failures cause rollback, and how to prove that the changes really disappeared.
This guide builds a small checkout application with plain SQL, Spring Boot and an in-memory database. You will run a successful checkout, cause a real database constraint failure, and inspect the committed state afterward. The download also includes deliberately broken examples, tests and editable UML and BPMN diagrams.
Research and verification: November 2, 2026. Intended for Java developers taking their first steps with Spring; basic methods, constructors and exceptions are enough to begin.
1. Why this topic now?
Spring's current release activity covers several directions. The official Spring Boot 4.1 release highlights include gRPC support, observability improvements and changes across Spring Data, Spring Security and Spring Modulith. The Spring blog also features Spring AI work on embeddings and model messages, alongside the Spring Boot 4.2 release candidate.
These are useful developments, but a beginner also needs the foundations that make application behavior trustworthy. An AI tool, REST endpoint or scheduled job can all invoke the same business operation. Each needs a clear answer to one question: which changes must succeed together?
For this installment, transaction boundaries are our editorial choice for the most useful next beginner topic. That is a judgment about learning value, rather than a measured ranking of market popularity. It builds on our first Spring Boot REST API guide and complements the broader DDD with Spring introduction.
The lab pins Spring Boot 4.1.2, which the official documentation lists as stable on the research date. Boot 4.2.0-RC1 is a preview. We use Java 21 and Maven; Boot's system requirements document the broader supported ranges. This tutorial does not depend on a release-candidate feature.
2. What is a transaction?
Imagine that checkout has two database steps:
- Reserve two units of
SPRING-BOOK. - Insert order
ORDER-001for those two units.
Our application promises that these changes belong together. A successful operation commits both. A failure that triggers rollback leaves neither change committed. The transaction boundary follows the business operation. In this lab, that operation is checkout.
The database performs commit and rollback; Spring coordinates the boundary around the Java call. A transaction is not a saved copy of your Java objects, and rollback does not rewind arbitrary code. A line already printed to the console remains printed.
Start with the local case: one application, one JDBC DataSource and one database transaction. The official Spring transaction guide introduces this same general approach to grouping database operations.
Our specific acceptance rule is easy to inspect: a failed attempt must leave both the stock and the number of orders at their previously committed values. We deliberately fail after the stock update so that an unchanged result demonstrates rollback of earlier work.
3. Run the complete example
Download the runnable transaction lab, JavaDoc and editable diagrams.
Install JDK 21 and Maven 3.6.3 or later. Extract the download, open a terminal in its examples directory, and run:
mvn verify javadoc:javadoc
java -jar target/transaction-lab-1.0.0.jar --spring.main.banner-mode=off --logging.level.root=ERROR
The first build downloads dependencies from Maven Central. The application needs no Docker, database installation, AI account or API key. It runs as a command-line program, prints the demonstration and closes its Spring context. It exposes no HTTP server.
The important output is:
INITIAL stock=10 orders=0
COMMITTED stock=8 orders=1
DUPLICATE: rejected; second reservation rolled back
AFTER ROLLBACK stock=8 orders=1
Every new application process starts with ten books and no orders because the database is in memory. This makes the demonstration repeatable. It also means that this configuration is unsuitable for retaining real orders.
The download includes docs/api/index.html with generated JavaDoc. Open it in your browser if you want to inspect the production classes and their contracts alongside the article. Build evidence and the exact test counts are recorded separately in the editorial package.
4. Understand the application structure
The code has six production classes:
| Class | Responsibility |
|---|---|
TransactionLabApplication |
Starts Spring Boot, then closes the application context. |
DemoRunner |
Calls checkout and prints the committed database state. |
CheckoutService |
Validates input and groups the two writes into a business operation. |
CheckoutRepository |
Executes parameterized SQL through JdbcTemplate. |
JakartaCheckoutService |
Demonstrates the standard Jakarta annotation on a Spring-managed bean. |
OutOfStockException |
Explains that the requested product cannot be reserved. |
A Spring bean is an object whose creation and dependencies Spring manages. The constructor of CheckoutService asks for a CheckoutRepository; Spring supplies it. The repository receives a JdbcTemplate in the same way.
JdbcTemplate is a helper that runs SQL and manages repetitive JDBC resource handling. Our adapter uses it so that the transaction lesson stays visible: you can see the stock update and order insert directly. There is no ORM dirty checking to learn at the same time. See Spring's JDBC core documentation for the underlying API.
The proxy in the diagram is an additional object Spring places in the call path. It will become important when we investigate a transaction that appears to be missing.
5. Create the database tables
The relevant schema is small. audit_log supports an advanced propagation test; the checkout itself uses the other two tables.
CREATE TABLE inventory (
sku VARCHAR(40) PRIMARY KEY,
available INTEGER NOT NULL CHECK (available >= 0)
);
CREATE TABLE purchase_order (
order_ref VARCHAR(80) PRIMARY KEY,
sku VARCHAR(40) NOT NULL REFERENCES inventory(sku),
quantity INTEGER NOT NULL CHECK (quantity > 0)
);
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
message VARCHAR(200) NOT NULL
);
INSERT INTO inventory(sku, available) VALUES ('SPRING-BOOK', 10);
The primary key on order_ref prevents two stored orders from sharing the same reference. The foreign key ties an order to an inventory row. The check constraints reject negative stock and nonpositive order quantities at the database boundary.
These rules complement the Java checks. We validate arguments early to give a clear error; database constraints protect stored data even if a future code path forgets a check.
The lab configuration is:
spring.application.name=transaction-lab
spring.main.web-application-type=none
spring.datasource.url=jdbc:h2:mem:transactionlab;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
lab.demo=true
DB_CLOSE_DELAY=-1 keeps this in-memory database alive while its JVM is running, even if the last connection closes. It does not make the data persistent across process exits. LOCK_TIMEOUT is an H2 setting used by this lab, not a portable SQL setting.
6. Write the SQL adapter
Here is the complete repository, including its JavaDoc:
package com.homannsoftware.transactions;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
/** Executes parameterized SQL against the lab's single DataSource. */
@Repository
public class CheckoutRepository {
private final JdbcTemplate jdbc;
/**
* Creates the SQL adapter.
* @param jdbc Spring's transaction-aware JDBC helper
*/
public CheckoutRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/**
* Decrements stock only when enough stock is still available.
* @param sku product identifier
* @param quantity positive amount, validated by the service
* @return true if one inventory row was updated
*/
public boolean reserve(String sku, int quantity) {
return jdbc.update("""
UPDATE inventory SET available = available - ?
WHERE sku = ? AND available >= ?
""", quantity, sku, quantity) == 1;
}
/**
* Inserts an order; a duplicate reference is a database constraint error.
* @param orderRef unique order reference
* @param sku reserved product
* @param quantity reserved amount
*/
public void recordOrder(String orderRef, String sku, int quantity) {
jdbc.update("INSERT INTO purchase_order(order_ref, sku, quantity) VALUES (?, ?, ?)",
orderRef, sku, quantity);
}
}
The ? placeholders separate values from SQL text. The adapter passes the quantity and SKU as arguments rather than building a statement by concatenation.
Notice the stock update. We do not first read the quantity, calculate a new value in Java, and write it back. Instead, one guarded statement reduces stock only if enough units remain. An affected-row count of one means that the reservation succeeded; zero means that no row satisfied the condition.
That distinction matters when two calls compete for the same product. Our H2 test starts two callers that each request seven units from a stock of ten. Exactly one succeeds, leaving three units and one order. This verifies this scenario on the bundled database. A production system should run equivalent contention tests against its actual database, isolation level and retry policy.
The missing-SKU case and the insufficient-stock case intentionally share one business error. An application that needs to distinguish them can introduce a separate lookup and error contract, but it must preserve the guarded write.
7. Define the transaction boundary
The service connects the two database operations:
package com.homannsoftware.transactions;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Defines the transaction boundary for one local checkout use case. */
@Service
public class CheckoutService {
private final CheckoutRepository repository;
/**
* Creates the service through constructor injection.
* @param repository SQL adapter using the same DataSource
*/
public CheckoutService(CheckoutRepository repository) {
this.repository = repository;
}
/**
* Reserves inventory and records an order as one atomic database change.
* @param orderRef nonblank reference of at most 80 characters
* @param sku nonblank product identifier of at most 40 characters
* @param quantity positive number of units
* @throws IllegalArgumentException if an argument is invalid
* @throws OutOfStockException if the product is missing or insufficient
*/
@Transactional
public void checkout(String orderRef, String sku, int quantity) {
if (orderRef == null || orderRef.isBlank() || orderRef.length() > 80
|| sku == null || sku.isBlank() || sku.length() > 40 || quantity <= 0) {
throw new IllegalArgumentException("Use a valid reference, SKU and positive quantity");
}
if (!repository.reserve(sku, quantity)) {
throw new OutOfStockException(sku);
}
repository.recordOrder(orderRef, sku, quantity);
}
}
Read the method as a small use case: validate the request, reserve stock, then record the order. The public method is the entry point that other beans call.
The annotation comes from org.springframework.transaction.annotation.Transactional. It asks Spring's transaction infrastructure to apply transaction behavior when the method is invoked through its managed proxy. The lab's JDBC starter and DataSource allow Boot to configure the necessary infrastructure, following the setup described in the Spring getting-started guide.
We keep this boundary on the service because checkout spans more than one SQL operation. Placing independent transactions around reserve and recordOrder would not express the same promise. The first call could commit before the second one fails.
Use the application as supplied when reproducing the result. Adding a second DataSource, changing the transaction manager or moving work to another execution model changes the assumptions behind this example.
8. Watch a real rollback
Both attempts start with eight units and an existing ORDER-001. Without a shared transaction, reducing stock by three commits five units; the duplicate order then fails and leaves that partial change. With one transaction, five is only the transaction’s working value; the failed insert triggers rollback and the committed stock remains eight. Both paths retain the one existing order. The committed baseline shown is not a guarantee of what every concurrent reader sees; visibility depends on isolation and database behavior.
The first call uses ORDER-001 and requests two units. The database accepts both statements. The proxy completes the transaction before checkout returns to the runner, so the next query observes eight units and one order.
The second call deliberately reuses ORDER-001 while requesting three more units. The stock statement runs first, taking the transaction's working value from eight to five. The subsequent insert violates the existing order's primary key.
Spring's JDBC exception translation exposes that failure as a DataIntegrityViolationException, an unchecked exception. Under the lab's default rollback rules, the transaction rolls back. A query after the failed service call again sees eight units and one order. Spring documents JDBC exception handling in its SQL exception translation reference.
The runner catches the exception outside the service boundary. At that point, rollback has already completed. This is a useful place to translate an application failure into a user-facing response or decide whether a separate action is appropriate.
Reusing a reference in this lab is a deliberate rejection. It is not a complete idempotency implementation: the service does not compare an incoming request with an earlier request and return the earlier result.
9. Understand the proxy trap
Consider this excerpt from the deliberately broken test fixture:
public void sameObjectCall() { runtimeFailure(); }
@Transactional
public void runtimeFailure() {
decrement();
throw new IllegalStateException("Demonstration failure");
}
Calling runtimeFailure() through the injected pitfalls bean passes through the proxy. In the test, the stock decrement is rolled back after the exception.
Calling sameObjectCall() produces a different route: the method invokes runtimeFailure() on the same object. That internal call bypasses the proxy. With no outer transaction in this fixture, the stock update commits independently, and the later exception leaves stock at nine.
This explains why a method can carry @Transactional and still produce a partial write. The behavior depends on the route into that method. Spring's proxying documentation explains the self-invocation limitation and why private methods cannot be advised through these proxies.
The practical fix is to expose the business boundary on a method that another Spring bean calls. If an outer method already has a transaction, an internal helper can run within that existing transaction; the internal call simply cannot establish its own annotation-driven settings through the proxy.
Do not solve this beginner problem with a hand-built new CheckoutService(...). That object would also bypass Spring's managed proxy. Constructor injection is how the runner receives the managed service in this application.
10. Choose exception rules deliberately
Our tests make three different outcomes visible:
| Failure path in this lab | Result after the call |
|---|---|
| Unchecked exception escapes a transactional method | Stock decrement rolls back. |
Checked IOException escapes with default rules |
Stock decrement commits. |
Checked IOException escapes with rollbackFor |
Stock decrement rolls back. |
| Locally thrown exception is caught inside the method | Method completes normally; stock decrement commits. |
Here is the explicit checked-exception rule from the test fixture:
@Transactional(rollbackFor = IOException.class)
public void checkedRollback() throws IOException {
decrement();
throw new IOException("Checked demonstration failure");
}
The default distinction between checked and unchecked exceptions is documented in Spring's rollback reference. An exception does not automatically imply rollback merely because the caller sees an error.
The final row concerns a local business exception caught before it reaches the transaction interceptor. It does not mean that catching every failure permits commit: a participating transactional call can already have marked the transaction rollback-only. The next section demonstrates that case.
Spring also supports a global ALL_EXCEPTIONS rollback default, available since Framework 6.2. That is an application-wide policy choice described in the transaction annotation settings. The lab deliberately retains the ordinary default so that you can reproduce the distinction, then uses a type-safe rollbackFor = IOException.class rule for the contrasting example.
11. Understand REQUIRED and REQUIRES_NEW
Propagation answers what should happen when a transactional operation calls another transactional operation.
REQUIRED, the default, joins an existing transaction or creates one if none exists. In our test, an outer bean catches a runtime exception from an inner transactional bean. The inner interceptor has already marked their shared transaction rollback-only. At outer completion, Spring throws UnexpectedRollbackException; catching the original exception did not restore the ability to commit.
REQUIRES_NEW creates an independent transaction and suspends the outer one. The lab uses it only in a test audit writer: an audit row commits even though the surrounding inventory operation later rolls back. This independence also means additional connection demand and possible contention. It should reflect an intentional business requirement, rather than serve as a way to suppress an inconvenient rollback.
These semantics and resource considerations are covered in Spring's transaction propagation reference. For a first application, keep the ordinary checkout on one REQUIRED boundary and introduce alternatives only when the intended outcome is clear.
12. Compare the Jakarta EE alternative
Jakarta EE provides a standard alternative: jakarta.transaction.Transactional, defined by Jakarta Transactions. In a Jakarta EE application, the container applies it to supported managed beans through a transactional interceptor. For a CDI service, that typically means a CDI-managed bean with injected dependencies and a container-managed DataSource. Merely adding the API JAR does not provide that runtime.
Spring also understands this standard annotation. Its transaction metadata reader supports the Jakarta/JTA annotation when present. The JtaTransactionAnnotationParser converts it into transaction attributes that Spring's infrastructure can apply.
Use the standard annotation inside this Spring application
The revised project explicitly adds this dependency; Spring Boot's dependency management selects version 2.0.1:
<dependency>
<groupId>jakarta.transaction</groupId>
<artifactId>jakarta.transaction-api</artifactId>
</dependency>
Here is the complete alternative service. It uses the same SQL adapter, but its transaction annotation comes from Jakarta rather than Spring:
package com.homannsoftware.transactions;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
/** Demonstrates the Jakarta Transactions annotation on a Spring-managed bean. */
@Service
public class JakartaCheckoutService {
private final CheckoutRepository repository;
/**
* Creates the alternative checkout service.
* @param repository SQL adapter sharing the application's DataSource
*/
public JakartaCheckoutService(CheckoutRepository repository) {
this.repository = repository;
}
/**
* Applies the same business operation using Jakarta transaction metadata.
* @param orderRef nonblank reference of at most 80 characters
* @param sku nonblank product identifier of at most 40 characters
* @param quantity positive number of units
* @throws IllegalArgumentException if an argument is invalid
* @throws OutOfStockException if the product is missing or insufficient
*/
@Transactional(Transactional.TxType.REQUIRED)
public void checkout(String orderRef, String sku, int quantity) {
if (orderRef == null || orderRef.isBlank() || orderRef.length() > 80
|| sku == null || sku.isBlank() || sku.length() > 40 || quantity <= 0) {
throw new IllegalArgumentException("Use a valid reference, SKU and positive quantity");
}
if (!repository.reserve(sku, quantity)) {
throw new OutOfStockException(sku);
}
repository.recordOrder(orderRef, sku, quantity);
}
}
@Service still makes this a Spring-managed bean. The runner continues to demonstrate the original service; the integration tests exercise both implementations. No CDI container or application server is added to this standalone lab.
Compare the annotation options
| Requirement | Spring annotation | Jakarta Transactions annotation |
|---|---|---|
| Import | org.springframework.transaction.annotation.Transactional |
jakarta.transaction.Transactional |
| Default transaction behavior | Propagation.REQUIRED |
TxType.REQUIRED |
| Independent transaction | propagation = Propagation.REQUIRES_NEW |
value = TxType.REQUIRES_NEW |
| Rollback for a checked exception | rollbackFor = IOException.class |
rollbackOn = IOException.class |
| Exception excluded from rollback | noRollbackFor = IOException.class |
dontRollbackOn = IOException.class |
| Isolation, timeout or read-only metadata | Available as annotation attributes | No equivalent attributes on this annotation |
| Nested transaction setting | Propagation.NESTED, subject to manager support |
No NESTED value in TxType |
| Select a particular Spring transaction manager | transactionManager = "name" |
No equivalent annotation attribute |
Jakarta's TxType API defines REQUIRED, REQUIRES_NEW, MANDATORY, SUPPORTS, NOT_SUPPORTED and NEVER. Its default leaves checked exceptions outside rollback unless a rule changes that behavior. For a matching exception in both Jakarta rule lists, dontRollbackOn takes precedence. Our additional test uses a broader rollbackOn = Exception.class and a specific dontRollbackOn = IOException.class to demonstrate an exemption.
Does Spring actually use Jakarta Transactions here?
The distinction is between annotation metadata and the transaction manager. A Jakarta annotation does not automatically select a JTA transaction manager. In this lab, Spring reads the Jakarta annotation, creates its usual proxy and executes the transaction through org.springframework.jdbc.support.JdbcTransactionManager. We assert that concrete manager type in a test and explicitly exclude JtaTransactionManager.
The added tests also prove a successful commit, duplicate-key rollback, checked-exception rollback via rollbackOn, an explicit rollback exemption, and the same self-invocation limitation seen with Spring's annotation. Changing the import does not change the proxy call path in a Spring-managed application.
Spring can also integrate with a Jakarta EE server's transaction service. Boot's JTA documentation describes detecting a JTA environment and using Spring's JtaTransactionManager to coordinate with it. Transactions across multiple XA resources additionally require suitable XA-capable resources and configuration. That deployment is separate from the local JDBC setup verified here; neither adding the API dependency nor changing the annotation establishes distributed atomicity.
Use the Jakarta annotation when its standard contract fits your application. Use Spring's annotation when you need its additional settings. The annotation is portable metadata; this particular class still contains Spring's @Service and a Spring-based SQL adapter, so moving the whole implementation to a Jakarta EE runtime requires adapting bean management and infrastructure too.
13. Test the committed result
This test exercises the real service, transaction manager and H2 database:
@Test
void duplicateOrderRollsBackEarlierInventoryUpdate() {
checkout.checkout("SAME", "SPRING-BOOK", 2);
assertThatThrownBy(() -> checkout.checkout("SAME", "SPRING-BOOK", 3))
.isInstanceOf(DataIntegrityViolationException.class);
assertThat(stock()).isEqualTo(8);
assertThat(orders()).isEqualTo(1);
}
The test class deliberately has no @Transactional annotation. It queries the database after each service invocation has completed, so its assertions observe the result of the service's commit or rollback.
An ambient transaction managed by the test framework could conceal a missing application boundary: the test might provide the transaction that production code lacks. Spring distinguishes test-managed and application transactions in its testing reference.
All 24 test cases passed, with zero failures, errors or skips. The cases cover successful checkout, duplicate-key rollback, insufficient stock, unknown products, invalid arguments, concurrent reservations, the actual Spring proxy, self-invocation, checked exceptions, swallowed local failure, both propagation examples, and six Jakarta integration cases. The Jakarta cases also check the configured metadata reader and the concrete local transaction manager.
Verification used Temurin JDK 21.0.11+10 and Maven 3.9.11. The executable JAR was also run separately from the test suite, and its four demonstration lines matched the output above. Strict JavaDoc generation completed without documentation warnings. The tests use real JDBC and H2; no mocked database stands in for commit or rollback.
This evidence does not establish PostgreSQL compatibility, throughput, deadlock handling, production durability or correctness under every possible interleaving. It establishes the specific behaviors represented in the downloadable tests.
14. Read the business process
The BPMN view describes the business outcome. Inside the local checkout subprocess, inventory is reserved and the order is recorded. Successful completion leads to confirmation. Its boundary error path leads to rejection with no changes from the failed attempt committed.
The subprocess is a normal BPMN subprocess annotated as a local database transaction. It is not the BPMN transaction-subprocess symbol and does not model compensation. The error event represents the application failure contract; no workflow engine maps Java exceptions automatically in this example.
The supplied .bpmn file is non-executable BPMN 2.0 documentation with diagram coordinates. Editable PlantUML sources accompany the UML views. These files let a team discuss the boundary independently of the Java implementation.
15. Know the limits and next steps
Checkout currently contains only local database work. That is a deliberate scope: adding payment, email or an AI model call creates additional questions about effects outside the database.
A database rollback cannot unsend an email or undo an HTTP request that another service has already accepted. An after-commit hook can order an action after successful completion, but does not by itself guarantee delivery if the process fails afterward. Spring's transaction-bound event documentation explains commit-phase listeners. A reliable delivery requirement needs a design that records delivery intent durably and handles retries and duplicates, such as an outbox.
Before adapting the lab to a real shop, decide how order references and retries work, move to persistent storage with migrations, test the target database, define authentication and authorization, and examine timeouts, connection capacity and contention. Keep remote calls outside long-held database transactions unless their placement is justified by the overall design.
For the next learning step, change the quantity, add a new database constraint and write an assertion about the state after a failure. Then compare the deliberately broken fixture with the working service. The central skill is being able to point to the boundary, predict what remains committed, and verify that prediction in a test.
If you are building a Spring application and need help defining these boundaries, contact Homann Software to discuss the business operation, failure cases and architecture together.