1. Transaction as the boundary of an operation
The Hibernate runtime cycle is already visible: one persistence context, dirty checking, flush, commit, rollback. Now the main practical question is where this boundary actually lives in the application. If it does not match one finished business operation, all of this beautiful mechanism falls apart into detached objects, extra save, and accidental merge.
To see this without extra noise, let's take a reduced order/inventory scenario: "confirm an order and reserve stock." These are not two operations "because there are two repositories," but one operation, because it has one meaning and one expected result: either the order is confirmed and the stock is reserved, or none of it happened. This is where the word "atomicity" usually comes up, but let me translate it into plain language: atomicity means "all or nothing," not "well, we saved half of it, and then we'll see."
Hibernate is designed precisely to serve one unit of work well: inside the boundary we read data, check conditions, change managed entities, and then Hibernate itself honestly performs the needed UPDATE on flush/commit. But this works only if we really have one boundary inside which one persistence context lives. And that is where today's lecture's main character appears: the service method as the right place for the transaction boundary.
2. Roles of Repository and Service
At an early stage of learning, it seems that Repository and Service differ only in "that's how it's done in Spring." Because of that, there is a temptation: "let's do everything in the repository" or "let's put the transaction where it is convenient for us." But these layers differ not in style, but in meaning: they answer different questions.
The repository answers the question "how do I fetch/save data." It is good at findById, save, delete, and sometimes expressive query methods and read-oriented fetches. The repository is a data access step. It usually should not know what "order confirmation" or "stock reservation" means as a business event. It may know how to find an InventoryItem by productId, but it should not decide in which order to change the order status and the quantities in stock.
The service answers the question "how do I perform the whole business operation." In other words, the service is the place where we assemble the scenario: load the necessary entities, check invariants, change state, and complete the operation with one predictable result. And that is exactly why the service layer is the natural place for the transaction boundary: because the transaction should match the boundary of one scenario, and the scenario lives in the service.
It is convenient to keep this in mind as a small table (without any religious fervor, just for orientation):
| Question | Usually answered by Repository | Usually answered by Service |
|---|---|---|
| "Where is the PurchaseOrder with this id?" | Yes | No |
| "How do I confirm an order correctly?" | No | Yes |
| "Do I need to change the InventoryItem together with the order?" | No | Yes |
| "What SQL will be issued when something changes?" | Indirectly | Consciously (through the scenario + SQL log) |
| "Where should the transaction be?" | Sometimes (by default) | Yes, as the common unit-of-work boundary |
Pay attention to "sometimes (by default)" in the repository column. This is an important nuance that often misleads people: Spring Data JPA does add transactionality to repository methods, but that does not mean you get transactional integrity for the business operation automatically. Let's see why.
And one more prerequisite for the service examples below: we are talking about a normal external call to a Spring bean public method. If the call goes around the proxy, the @Transactional boundary itself may not be activated, and then this whole model simply will not turn on.
3. Transactions in repositories: what breaks
From the outside, it seems logical: "the repository is transactional anyway, so everything is fine." Especially when the code is short and the tests are green. The problem is that repository transactionality by default is the transactionality of each individual call, not of the whole operation.
Let's look at the "bad" version, which is very easy to write out of habit:
import org.springframework.stereotype.Service;
@Service
public class CheckoutService {
public void confirmOrderBad(Long orderId, Long productId, int qty) {
// Step 1: load the order via a separate repository call (often in its own small Tx)
PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();
// Important: if there is no external @Transactional, by this point the entity easily becomes detached
order.setStatus(OrderStatus.CONFIRMED);
// Step 2: save() here often turns into a merge-flow (with extra SELECTs)
orderRepository.save(order);
// Step 3: the same story repeats for the inventory entity
InventoryItem item = inventoryRepository.findByProductId(productId).orElseThrow();
item.reserve(qty);
// Step 4: a second merge-flow, and in the end we get a "chain of saves" rather than one scenario
inventoryRepository.save(item);
}
}
The code looks decent: found the order, changed the status, saved; found the stock, reserved, saved. But if we remember our entity state model, the interesting movie starts.
When you call orderRepository.findById(...) without an external service transaction, this call usually happens in its own small transaction (at least read-only). The repository performs a SELECT, returns the object to you... and immediately ends its transaction. At that moment, PurchaseOrder changes from managed to detached, because the persistence context is already closed.
Then you call order.setStatus(...). In Java, this always works: the setter knows nothing about databases or transactions, it honestly changes the field. But Hibernate no longer controls this object as a managed entity — the snapshot, dirty checking, and normal unit-of-work logic are no longer involved here. And when you call orderRepository.save(...), you are essentially starting a detached flow, which we already covered in the module about merge: Spring Data will very often be forced to apply merge in order to "transfer the detached object's state into a managed copy."
And that is where two unpleasant effects appear. The first is cost and unpredictability: merge can lead to extra SELECT, because Hibernate has to understand the current state in the database and create/get a managed instance into which it will copy data. The second is semantic: your operation turns into a chain of separate saves rather than one unified scenario.
The inventory side (InventoryItem) repeats the same story: findByProductId returned an entity in one transaction, then it became detached, and then save again triggers merge semantics.
If we look at the SQL log (very roughly, without column details), the "bad" scenario often looks like this:
-- Tx #1 (repository read-only): we just read the order and immediately "close the world of managed entities"
SELECT ... FROM purchase_order WHERE id = ?;
-- Tx #2 (save -> merge): we re-create the managed copy to apply changes from the detached object
SELECT ... FROM purchase_order WHERE id = ?; -- to create a managed copy
UPDATE purchase_order SET status = ? WHERE id = ?;
-- Tx #3 (repository read-only): we read the inventory entity in a separate transaction
SELECT ... FROM inventory_item WHERE product_id = ?;
-- Tx #4 (save -> merge): merge-flow again and update already in a new Tx
SELECT ... FROM inventory_item WHERE id = ?;
UPDATE inventory_item SET available_qty = ?, reserved_qty = ? WHERE id = ?;
You can debate the nuances, but the overall shape is very recognisable: many short transactions, many separate persistence contexts, many repeated SELECT.
Now let's add real life: what if reserve(qty) inside InventoryItem finds that there is no stock left (or not enough) and throws an exception? A very common situation. In our "bad" scenario, by that moment orderRepository.save(order) may already have committed in its own transaction. The result is that the order is confirmed, and the reservation is not made. The system enters a state that is "logically impossible, but sitting in the database."
To feel how this falls apart on the level of "how it breaks down," it is useful to look at the sequence of calls as several independent mini-operations:
sequenceDiagram
participant S as CheckoutService
participant OR as OrderRepository
participant IR as InventoryRepository
participant DB as PostgreSQL
S->>OR: "findById(orderId)"
Note over OR,DB: "Tx #1 (read-only)"
OR->>DB: SELECT order
S->>OR: "save(order)"
Note over OR,DB: "Tx #2 (write, merge-flow)"
OR->>DB: "SELECT order (merge)"
OR->>DB: UPDATE order
S->>IR: "findByProductId(productId)"
Note over IR,DB: "Tx #3 (read-only)"
IR->>DB: SELECT inventory
S->>IR: "save(item)"
Note over IR,DB: "Tx #4 (write, merge-flow)"
IR->>DB: "SELECT inventory (merge)"
IR->>DB: UPDATE inventory
The main problem is not that "4 transactions are bad for performance" (though that is also true). The main problem is that we lost the integrity of the business operation. And then, once you start chasing "weird" bugs, you will patch them with hacks: "let's read it again," "let's save it again," "let's try saveAndFlush" — and in the end you get the noisiest possible persistence layer.
4. One operation — one service transaction
After the previous picture, you want to make a simple move: put @Transactional on the service. And that really is a simple move... with a disproportionately big effect. Because it restores the correct Hibernate runtime model for us: one operation = one persistence context = one unit of work. In this reduced order/inventory scenario, this is especially clear: we are not creating a new PurchaseOrder yet, we are simply isolating the fact of one shared boundary.
Here is the same scenario, but now "properly":
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CheckoutService {
@Transactional
public void confirmOrder(Long orderId, Long productId, int qty) {
// Both objects are loaded within one transaction and one persistence context
PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();
InventoryItem item = inventoryRepository.findByProductId(productId).orElseThrow();
// Change state through domain methods: this makes the scenario read like a scenario
order.confirm();
item.reserve(qty);
// Important: explicit save() is usually not needed for already loaded managed entities
// Hibernate will do the UPDATE itself on flush/commit (dirty checking)
}
}
What matters here is not that "we added an annotation." What matters is that now the entire method runs inside one transaction boundary. Which means:
First, order and item become managed objects in one persistence context after loading. This means that the changes we make with the confirm() and reserve() methods will be automatically picked up by dirty checking. We do not need to mechanically call save after every change, because Hibernate will see the changes itself on flush.
Second, if reserve(qty) throws an exception (for example, "not enough stock"), the entire operation will roll back. The database will not see a "partially successful order confirmation." We get back that same "all or nothing."
Third, we stop accidentally provoking detached flow. In other words, we use merge less as a "magic hammer" and more often live in a controlled managed-update style, which is easier to predict from the SQL.
In the SQL log, this usually looks simpler and cleaner:
-- One transaction for the entire confirmOrder(...)
SELECT ... FROM purchase_order WHERE id = ?;
SELECT ... FROM inventory_item WHERE product_id = ?;
-- On flush/commit Hibernate synchronises the changes in one batch within the same Tx
UPDATE purchase_order SET status = ? WHERE id = ?;
UPDATE inventory_item SET available_qty = ?, reserved_qty = ? WHERE id = ?;
Yes, the order and exact details depend on the mapping and on what exactly you change, but the key idea remains: you see one scenario, not a set of separate "went to the DB — came back — went again" steps.
To hammer the last nail into the coffin of "let's put the transaction on the repository," let's compare this in one table:
| Characteristic | "Transactions on repositories" | "Transaction on the service" |
|---|---|---|
| How many units of work per business operation | Often several | Exactly one |
| Entity state between calls | Often detached | managed inside the method |
| Role of save | Often a forced merge | Usually needed only for new entities |
| Risk of "partially saved" data | High | Low (atomicity) |
| Predictability of the SQL trace | Medium/low | High |
| Log-based diagnostics | "why did it go to the DB so many times?" | "here is the scenario, here is its SQL" |
And that is all without any advanced techniques. Just the right place for the boundary.
Transaction size: too narrow and too broad
After you accept the idea that "the transaction lives in the service," a second typical mistake appears: a pendulum swing. First a person lives with transactions on repositories (too narrow), then gets scared and makes one huge @Transactional "just in case" (too broad). And once again, problems appear — just of a different kind.
A too-narrow transaction, as we saw, breaks into detached flow, merge, repeated SELECT, and "partially successful" operations. A too-wide transaction more often breaks the system in another way: you start keeping the database "under tension" longer than necessary. This shows up as long locks, strange delays, growing load, and sometimes plain old "everything is slow, but it is not clear why." In a training project this may not be so dramatic, but in real life it is like setting the coffee machine to "infinite brewing mode": there is coffee, but the whole kitchen is paralysed.
A practical criterion for transaction size sounds boring, but it works: the transaction should cover the minimal amount of code needed to obtain one consistent result. Inside it usually live data reads for decision-making, invariant checks, and state changes. And everything that is not part of the "consistent result in the database" is better left outside.
For example, if after confirming an order you want to generate a nice PDF, send an email, call an external HTTP service, or perform a long discount calculation — this is almost never something you want to keep inside the same DB transaction. In our course we deliberately keep the project simple and do not go into integrations, but the rule is still worth remembering: a short transaction is easier to diagnose and cheaper for the system, while a coherent transaction preserves data correctness.
5. Techniques for service code
Now that the transaction place has been chosen correctly, we want to take a second step: write service code in a way that makes it convenient to read and compare with the SQL log. In the lab project, this is especially important: we learn not "so that it works," but "so that it is clear why it works that way."
The first technique is to express changes through domain methods, rather than through a bunch of setters in the service. Yes, you can write order.setStatus(CONFIRMED), but it is much clearer to read order.confirm(). First, this makes the scenario code look like a scenario, not field manipulation. Second, a domain method is a good place for small rules: "you cannot confirm a cancelled order," "you cannot confirm twice," and so on. And these rules will live close to the data, instead of spreading across services.
The second technique is to check invariants where they logically belong. For example, reserving stock is not just "minus and plus," but an operation with constraints. In InventoryItem, it may look like this:
public void reserve(int qty) {
// Invariant: you cannot reserve more than is available
if (availableQty < qty) {
// Important: the exception should "cut off" the scenario
// With service @Transactional, this will roll back the entire operation
throw new NotEnoughStockException(product.getId(), availableQty, qty);
}
// The business operation itself: move quantity from available to reserved
availableQty -= qty;
reservedQty += qty;
}
Notice that this code is very "plain Java," without any magic. But combined with @Transactional on the service, it gives an important effect: if the exception is thrown, the transaction will not commit, and you will not get a partially applied scenario.
The exception itself can be as simple as possible, as long as it is meaningful and, in our context, unchecked so that the code does not turn into "try/catch for the sake of it":
public class NotEnoughStockException extends RuntimeException {
public NotEnoughStockException(Long productId, int have, int need) {
// The message should help understand "how much there was" and "how much was requested"
super("Product " + productId + ": have=" + have + ", need=" + need);
}
}
The third technique is not to force external code to "orchestrate" repositories. A very common architectural pain looks like this: a controller (or some outer layer) calls orderRepository, then inventoryRepository, then something else, and then "somewhere" tries to understand why the state is odd. It is better when the outer layer tells the service: "perform the use case," and the service then does the necessary steps inside. This is not about "beauty" — it is about the fact that the responsibility boundary matches the transaction boundary.
The fourth technique is not to return managed entities to the outside "to continue the party." Even if you have a transaction inside the service, it ends when the method returns. If you return an entity outside and somebody starts touching its lazy fields or changing its state, you are back in the world of detached entities and unexpected consequences. It is much calmer to live when the service returns an identifier, status, DTO, or another "external" representation of the result, not the Hibernate-managed object itself.
6. Typical mistakes when working with transactions
Before we move on for the day, it is useful to record several "rakes" that almost everyone steps on when they first start deliberately placing transactions. These mistakes are especially tricky because the code can work for weeks and then suddenly explode in production (or, in a training project, explode exactly when you enable the SQL log and decide, "well, now I will understand everything").
Mistake #1: relying on repository transactionality as the transactionality of the business operation.
Yes, Spring Data JPA methods are often transactional by default, but those are transactions for each call. As soon as your operation touches two repositories or two steps read→check→change, without a service boundary you turn one scenario into a set of independent mini-scenarios. This almost certainly leads to detached flow, merge, and risky "partially saved" states.
Mistake #2: continuing to mechanically call save after every setter.
If the service method is transactional and the entity was loaded inside it, it is managed. Hibernate will see the changes on flush anyway. Mechanical save usually either does nothing useful or makes things worse (for example, it accidentally provokes merge semantics, extra queries, or an early flush at the wrong time).
Mistake #3: spreading one operation across several service methods without a shared transaction.
Sometimes a developer does "beautiful decomposition": one method changes the order, another changes stock, a third does something else, and all of it is called in sequence. If there is no single common service entry point with a transaction above it, you again get a fragmented unit of work. The code will "look architectural," but the behaviour of the persistence layer will become random.
Mistake #4: making the transaction too wide "just in case."
When @Transactional turns into a wrapper "around half the application," you start keeping the database and the persistence context alive longer than needed. In a training project this appears as noisy SQL and difficult diagnostics, while in a real system it often becomes delays and concurrency problems. A transaction should be exactly as large as needed to guarantee a consistent result, and no larger.
Mistake #5: returning an entity to the outside and expecting it to "still be alive."
Returning an entity from a service looks convenient until you run into lazy loading and detached state. On the way out of the service, the transaction ends, the persistence context closes, and any attempt to "read a bit more" or "slightly adjust" the entity now happens outside that managed world where Hibernate was your ally. As a result, you either get exceptions or silently slide back into a merge-oriented style.
GO TO FULL VERSION