@Transactional has appeared in every example of the last three lessons without our ever explaining it. We have put it on the services, we have needed it in @Modifying, we have said that the persistence context lives as long as the transaction, that dirty checking saves without calling save() and that readOnly = true optimises something. All of those are claims still awaiting justification. This lesson justifies them.
And it goes further, because transactions are where CicloUrbana's correctness is really decided. Starting a rental requires marking the bike as rented and creating the rental record: if the first happens without the second, a bike is locked away forever with nobody holding it; if the second happens without the first, two Ribalta citizens can rent the same bike. The transaction is what prevents an intermediate state from existing. We will also see the two traps that sometimes make @Transactional do nothing at all, with no warning: they are responsible for a disproportionate number of production errors.
Contents
- What a transaction is: ACID in CicloUrbana
- Declarative management with
@Transactional - Where it goes and why
- How it works underneath: the AOP proxy
- Trap 1: self-invocation
- Trap 2: non-public methods
propagation: the seven valuesisolation: the four levelsreadOnly,timeoutand the remaining attributes- The rollback rule
- The persistence context and flushing
- Pessimistic versus optimistic locking
TransactionTemplate: programmatic management- Transactional events
- Transactions and
LazyInitializationException - Common Mistakes and Tips
- Exercises
- What a transaction is: ACID in CicloUrbana
A transaction is a unit of work that runs all or nothing. Its classic definition is the four ACID properties, and it is worth seeing them over the real case: starting a rental at the "Main Square" station.
@Transactional
public RentalResponse start(StartRentalRequest request) {
Bike bike = bikeRepository.findById(request.bikeId())
.orElseThrow(() -> new ResourceNotFoundException("Bike", request.bikeId()));
if (!bike.canBeRented(networkProperties.batteryThreshold())) {
throw new BikeUnavailableException(bike.getPlate());
}
bike.setStatus(BikeStatus.IN_USE); // (1)
bike.setStation(null); // (2) it leaves the station
Rental rental = new Rental();
rental.setUser(userRepository.getReferenceById(request.userId()));
rental.setBike(bike);
rental.setOriginStation(
stationRepository.getReferenceById(request.originStationId()));
rental.setStartedAt(Instant.now());
rentalRepository.save(rental); // (3)
events.publishEvent(new RentalStarted(rental.getId(), Instant.now()));
return mapper.toResponse(rental);
}The three marked operations must happen together.
| Property | What it guarantees | In CicloUrbana |
|---|---|---|
| Atomicity | All or nothing | If the rental INSERT fails, the bike goes back to AVAILABLE |
| Consistency | Constraints are respected | A rental cannot be created with a non-existent bike_id |
| Isolation | Concurrent transactions do not tread on each other | Two users do not rent the same bike |
| Durability | Committed means permanent | After the 200 OK, a power cut does not lose the rental |
Atomicity is best seen through the counter-example. Without a transaction, every repository operation commits separately: the UPDATE bikes SET status='IN_USE' commits, and if the INSERT INTO rentals fails afterwards, bike RB-0142 is left IN_USE with no rental backing it.
That inconsistent state is especially damaging because it produces no visible error: the bike simply disappears from the available inventory and nobody knows why. With @Transactional, the exception causes a rollback and both changes are undone.
- Declarative management with
@Transactional
@TransactionalSpring offers two ways to manage transactions: declarative (an annotation) and programmatic (explicit code). The declarative one is the usual choice because it separates management from business logic.
That is all. Behind that annotation the following happens: a connection is taken from the pool and set to autoCommit = false; a persistence context tied to the transaction is opened; the method runs; if it finishes cleanly there is a flush and a commit, and if it throws an unchecked exception, a rollback; and finally the context is closed and the connection returns to the pool.
The import matters. There are two annotations with the same name:
| Annotation | Origin | Recommendation |
|---|---|---|
org.springframework.transaction.annotation.Transactional |
Spring | Use this one. It has every attribute |
jakarta.transaction.Transactional |
Jakarta EE | It works, but without readOnly, isolation or timeout |
In CicloUrbana we always use Spring's.
- Where it goes and why
The short answer: on the service layer. The long one explains why not on the other two.
| Layer | @Transactional? |
Why |
|---|---|---|
| Controller | No | The transaction would live through JSON serialisation, holding a connection from the pool; it also mixes responsibilities |
| Service | Yes | This is where the use case lives, the unit of work with business meaning |
| Repository | No (it already has one) | SimpleJpaRepository is annotated; each method is its own transaction if none is in progress |
Why the service is the right boundary. A use case —"start a rental"— is exactly what must be atomic. A repository method is too small: if start were the sum of three independent transactions, there would be no atomicity. A controller is too big: it includes validation, mapping and serialisation, work that needs no open connection. The recommended pattern, which we already used in 04-05:
@Service
@Transactional(readOnly = true) // by default: read-only
public class RentalService {
@Transactional // the ones that write declare it explicitly
public RentalResponse start(StartRentalRequest request) { /* ... */ }
@Transactional
public RentalResponse finish(Long id, FinishRentalRequest request) { /* ... */ }
public PageResponse<RentalResponse> list(Pageable pageable) { /* reads only */ }
}Having readOnly = true as the default has two advantages: it optimises every read and it turns writing into a conscious decision, so a method that forgets to annotate itself will not write by accident.
- How it works underneath: the AOP proxy
Here is the key to understanding the two traps in the next section.
@Transactional does not modify your code. Spring creates a proxy that wraps your bean —the same BeanPostProcessor mechanism from 02-03 and from the repositories of 04-05—. What gets injected into the controller is not your RentalService: it is a proxy that contains it.
sequenceDiagram
participant C as RentalController
participant P as RentalService proxy
participant TM as JpaTransactionManager
participant S as RentalService (real)
C->>P: start(request)
P->>TM: is there a transaction? No -> open one
TM->>TM: connection from the pool, autoCommit=false
P->>S: start(request)
S-->>P: RentalResponse
P->>TM: commit (flush + COMMIT)
P-->>C: RentalResponse
If the method throws an unchecked exception, the proxy asks for a rollback instead of a commit and rethrows it.
Spring creates the proxy with a JDK dynamic proxy if the bean implements interfaces, or with CGLIB by generating a subclass if it does not; Spring Boot uses CGLIB by default (spring.aop.proxy-target-class=true), which explains the requirement that transactional methods be neither final nor private.
The fundamental consequence: only calls that go through the proxy activate the transaction. A call from one method of the class to another of the same class goes through this and does not pass through the proxy. Hence trap 1.
- Trap 1: self-invocation
It is the most frequent mistake and the hardest to detect, because it produces no symptom until something fails halfway through.
@Service
public class RentalService {
public void processReturnBatch(List<Long> ids) {
for (Long id : ids) {
finishWithTransaction(id); // internal call: this.finishWithTransaction()
}
}
@Transactional
public void finishWithTransaction(Long id) {
// This annotation has NO effect at all when called from above!
}
}The call finishWithTransaction(id) is really this.finishWithTransaction(id). this is the real object, not the proxy, so the annotation is ignored entirely. Each operation auto-commits as if there were no transaction, and a failure midway through the batch leaves the earlier returns committed and the later ones undone.
Neither the compiler nor Spring says a word. The code looks correct.
The three solutions, from best to worst. A. Extract to another bean (the recommended one):
@Service
public class ReturnProcessor {
private final RentalService rentalService; // injected: it is the PROXY
public void processBatch(List<Long> ids) {
ids.forEach(rentalService::finishWithTransaction); // goes through the proxy
}
}Besides working, it usually improves the design: orchestrating the batch and running the individual operation are different responsibilities.
B. Self-injection. It works and it is ugly: inject into the class itself a field @Lazy private final RentalService self —the proxy of itself— and call self::finishWithTransaction. @Lazy is needed to break the dependency cycle (02-02).
C. TransactionTemplate (section 13): programmatic management inside the method itself, with no proxy in the way. This trap applies equally to @Cacheable (09-02), @Async (07-03) and @PreAuthorize (05-05): every proxy-based annotation fails on self-invocation.
- Trap 2: non-public methods
@Transactional
private void privateMethod() { } // does NOT work
@Transactional
protected void protectedMethod() { } // does NOT work reliably
@Transactional
void packageMethod() { } // does NOT work reliablyWith CGLIB proxies, Spring generates a subclass that overrides the methods: a private one cannot be overridden, and protected or package-private ones are not intercepted reliably. The annotation is ignored silently. Since Spring Framework 6.0 startup logs a warning when it detects this, which helps, but the rule remains simple: @Transactional only on public methods, and never on final methods or classes.
propagation: the seven values
propagation: the seven valuespropagation answers: what should happen if a transaction is already in progress when this method is called?
| Value | If there is a transaction | If there is none |
|---|---|---|
REQUIRED (default) |
It joins it | It creates a new one |
REQUIRES_NEW |
It suspends the current one and creates an independent one | It creates a new one |
SUPPORTS |
It joins it | It runs without a transaction |
NOT_SUPPORTED |
It suspends the current one | It runs without a transaction |
MANDATORY |
It joins it | It throws an exception |
NEVER |
It throws an exception | It runs without a transaction |
NESTED |
It creates a savepoint | It creates a new one |
The three that really matter, with CicloUrbana cases:
REQUIRED: 95% of cases. RentalService.start calls BikeService.markRented; both are @Transactional and the second joins the first one's transaction: a single transaction and a single commit.
REQUIRES_NEW: recording something that must survive a rollback.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(Long userId, String operation, boolean successful) {
auditLogRepository.save(
new AuditLog(userId, operation, successful, Instant.now()));
}If the rental attempt fails and the main transaction rolls back, the audit record is preserved, because it lived in its own transaction. That is exactly what you want from an audit trail: to record what went wrong too.
Its cost has to be known: REQUIRES_NEW uses a second connection from the pool while the first one stays suspended. With maximum-pool-size: 10, ten concurrent requests using REQUIRES_NEW drain the pool and produce a deadlock: each thread waits for a connection that another suspended thread will never release. It is a real and hard-to-diagnose cause of production outages.
NESTED: undoing a part without losing the rest. It creates a savepoint inside the current transaction; if it fails, it rolls back to it and the rest survives. It requires JDBC support —PostgreSQL has it— but it does not work with JpaTransactionManager, only with DataSourceTransactionManager, which in practice rules it out in JPA applications.
isolation: the four levels
isolation: the four levelsisolation controls how much one transaction can see of other transactions' work in progress. The phenomena that can occur:
| Phenomenon | What it is | Example in CicloUrbana |
|---|---|---|
| Dirty read | Reading uncommitted data that is later undone | Seeing a bike as IN_USE in a transaction that ends in a rollback |
| Non-repeatable read | Reading the same row twice with different values | A station's capacity changes between two reads |
| Phantom read | A query returns new rows when repeated | Counting available bikes twice and getting a different number |
And the levels that prevent them:
| Level | Dirty read | Non-repeatable | Phantom | Cost |
|---|---|---|---|---|
READ_UNCOMMITTED |
Possible | Possible | Possible | Minimal |
READ_COMMITTED |
No | Possible | Possible | Low |
REPEATABLE_READ |
No | No | Possible* | Medium |
SERIALIZABLE |
No | No | No | High |
PostgreSQL uses READ_COMMITTED by default, and it is the right level for practically all of CicloUrbana. Two of its peculiarities are worth knowing: it does not implement READ_UNCOMMITTED —asking for it gives you READ_COMMITTED, because it never permits dirty reads— and its REPEATABLE_READ also prevents phantoms, being implemented with snapshots (MVCC), which makes it stronger than the standard demands. Raising it makes sense in a monthly report that must see a coherent snapshot: @Transactional(isolation = Isolation.REPEATABLE_READ).
The practical rule: do not touch isolation unless you know exactly why. Raising it increases locking and the likelihood of serialisation errors that force retries. For 99% of cases, READ_COMMITTED plus the optimistic locking of @Version (04-03) is the right combination.
readOnly, timeout and the remaining attributes
readOnly, timeout and the remaining attributesreadOnly = true is not a mere declaration of intent: Hibernate switches the FlushMode to MANUAL and stops flushing automatically; it skips dirty checking, keeping no copies of each entity's original state, which noticeably reduces memory in queries returning many rows; and it marks the JDBC connection as read-only, which in some setups allows it to be routed to a replica. In a query returning 10,000 entities, not holding 10,000 copies is a substantial saving: that is why readOnly = true at class level is a good practice and not an ornament.
Beware of one mistaken expectation: readOnly does not prevent writing. With Hibernate, an explicit INSERT can still go through. It is an optimisation, not a security barrier.
timeout caps the duration in seconds (@Transactional(timeout = 10)). When exceeded, TransactionTimedOutException is thrown and a rollback happens. It is a valuable safety net, because a transaction that drags on holds a connection from the pool and locks rows; in CicloUrbana it makes sense on operations that can degenerate, such as reports with open-ended filters.
| Attribute | What it is for | Default value |
|---|---|---|
propagation |
Behaviour when a transaction already exists | REQUIRED |
isolation |
Isolation level | The engine's (DEFAULT) |
readOnly |
Read optimisation | false |
timeout |
Maximum duration in seconds | No limit |
rollbackFor |
Checked exceptions that cause a rollback | None |
noRollbackFor |
Exceptions that must not cause one | None |
- The rollback rule
By default, Spring rolls back only on RuntimeException and Error. Checked exceptions commit the transaction.
It is an EJB legacy that surprises everybody:
@Transactional
public void operation() throws IOException {
repository.save(entity);
throw new IOException("network failure"); // it COMMITS! The save is confirmed
}
// To change it: @Transactional(rollbackFor = Exception.class)In CicloUrbana the problem does not arise because CicloUrbanaException, the root of the 03-06 hierarchy, extends RuntimeException. ResourceNotFoundException, StationFullException, BikeUnavailableException and the rest cause a rollback automatically. It was a good design decision and this is one of the reasons.
The classic mistake: catching the exception and losing the rollback.
@Transactional
public void startWithChargeWrong(StartRentalRequest request) {
Rental rental = createRental(request);
try {
paymentGateway.charge(rental.getTotalAmount());
} catch (PaymentDeclinedException e) {
log.error("Payment declined", e); // caught and "handled"
}
// The transaction COMMITS: the rental ends up created WITHOUT being charged
}Catching the exception stops it from reaching the proxy, and with no exception the proxy commits. The rental is recorded even though the payment failed.
And an even more baffling variant: if the exception is thrown in an inner method that is also @Transactional (propagation REQUIRED) and caught in the outer one, the transaction was already marked for rollback by the inner one, and on commit UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only is raised. The two correct solutions:
// A. Rethrow as a domain exception (the usual choice)
catch (PaymentDeclinedException e) {
log.error("Payment declined for rental {}", rental.getId(), e);
throw new BusinessRuleException("PAYMENT_DECLINED", "The payment was declined");
}
// B. Explicitly mark the transaction for rollback
catch (PaymentDeclinedException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}A is preferable: the @RestControllerAdvice from 03-06 turns it into a ProblemDetail with its code, and the client knows what happened.
- The persistence context and flushing
The persistence context lives exactly as long as the transaction. That is the link we have been announcing since 04-01.
When Hibernate flushes, that is, when it sends the accumulated SQL to the database:
| Moment | Is there a flush? |
|---|---|
| Before the commit | Always |
| Before a JPQL query that could be affected | Yes (FlushMode.AUTO) |
On calling entityManager.flush() or saveAndFlush() |
Yes, explicitly |
With readOnly = true |
Not automatically |
On calling save() on a new entity |
Not necessarily: it only assigns the id |
The automatic flush before a query is more important than it looks: saving a new station and immediately querying existsByName returns true because Hibernate pushes out the pending INSERT before running the query, even though at the moment of the save it had only assigned the id.
Dirty checking, in detail. On loading an entity, Hibernate keeps a copy of its state (the snapshot); at flush time it compares field by field and generates an UPDATE only if something changed, with only the modified columns if dynamic SQL is enabled.
@Transactional
public void adjustCapacity(Long id, int newCapacity) {
Station station = stationRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
station.setCapacity(newCapacity);
// No save(). On commit:
// UPDATE stations SET capacity=?, version=? WHERE id=? AND version=?
}It is correct and it is idiomatic. Calling save() here is redundant.
The hidden cost of dirty checking: keeping a copy of every loaded entity consumes memory, and comparing them all at each flush consumes CPU. That is why readOnly = true on queries is a real optimisation.
- Pessimistic versus optimistic locking
In 04-03 we added @Version and saw optimistic locking. Here comes its complement.
Optimistic (@Version) |
Pessimistic (@Lock) |
|
|---|---|---|
| Strategy | Detects the conflict on writing | Prevents the conflict by locking the row |
| Locks rows | No | Yes, until the end of the transaction |
| Cost | One column | Contention; risk of deadlock |
| It fails | At commit (409) |
It waits, or times out |
| Suitable for | Rare conflicts | Frequent conflicts over a scarce resource |
The case where optimistic locking is not enough in CicloUrbana is the race for a station's last bike: two citizens tap "rent" at the same moment at "Main Square", both transactions read the same bike as AVAILABLE and both mark it IN_USE. With @Version, the second commit fails with a 409, which is correct but a poor experience: the user gets an error when we could have assigned them another bike.
Pessimistic locking avoids it by serialising access:
public interface BikeRepository extends JpaRepository<Bike, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("""
select b from Bike b
where b.station.id = :stationId
and b.status = com.ciclourbana.bikes.BikeStatus.AVAILABLE
and b.batteryLevel >= :threshold
order by b.batteryLevel desc
limit 1
""")
Optional<Bike> lockBestAvailable(@Param("stationId") Long stationId,
@Param("threshold") int threshold);
}It generates a SELECT ... FOR UPDATE: the row stays locked until the end of the transaction and the second one waits. When the first commits, the second re-reads, sees it is no longer available and looks for another. Nobody gets a 409.
The available modes: PESSIMISTIC_READ (shared lock: others read, they do not write), PESSIMISTIC_WRITE (exclusive, SELECT ... FOR UPDATE), PESSIMISTIC_FORCE_INCREMENT (exclusive and it also increments @Version), OPTIMISTIC (checks the version at the end) and OPTIMISTIC_FORCE_INCREMENT (increments it even if nothing changed).
Three rules for pessimistic locking: always set a timeout, because without one a long transaction can block the rest indefinitely; keep the transaction as short as possible, since the lock lasts until the commit; and always lock rows in the same order across every method, or two transactions locking A→B and B→A will produce a deadlock that PostgreSQL will resolve by killing one of them.
The criterion for choosing: optimistic by default —it is what Station, Bike and Rental carry—, and pessimistic only at the specific points of high contention over a scarce resource.
TransactionTemplate: programmatic management
TransactionTemplate: programmatic managementWhen the annotation does not fit —fine control over the scope, transactions inside a loop, or to dodge self-invocation—, TransactionTemplate gives explicit control:
@Service
public class StationImporter {
private final TransactionTemplate template; // new TransactionTemplate(manager)
private final StationRepository stationRepository;
public ImportResult importBatch(List<CreateStationRequest> batch) {
int succeeded = 0, failed = 0;
for (CreateStationRequest row : batch) {
try {
// One transaction PER ROW: a failure does not drag the rest down
template.executeWithoutResult(status -> {
stationRepository.save(mapper.toEntity(row));
});
succeeded++;
} catch (DataAccessException e) {
log.warn("Row discarded: {}", row.name(), e);
failed++;
}
}
return new ImportResult(succeeded, failed);
}
}This case —importing Ribalta's station catalogue from a CSV with potentially faulty rows— is the canonical example: with @Transactional on the method, a single error would void the whole import; with one transaction per row, what is valid gets imported and what is discarded gets logged.
@Transactional |
TransactionTemplate |
|
|---|---|---|
| Readability | Better | Worse (extra code) |
| Control over the scope | The whole method | Exact |
| Affected by self-invocation | Yes | No |
| Transactions in a loop | No | Yes |
The rule: @Transactional by default; TransactionTemplate when the annotation cannot reach.
- Transactional events
In 02-02 we published the RentalStarted event. A plain @EventListener runs synchronously and inside the transaction, which produces two serious problems.
Problem 1: you notify something that may never happen. If the listener sends an email and the transaction then rolls back, the email announces a rental that does not exist. Problem 2: a connection is held during the external call. Charging through the gateway can take two seconds, and for that whole time the connection stays lent out: the cause of pool exhaustion we analysed in 04-02.
@TransactionalEventListener solves both:
@Component
public class RentalNotifier {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(RentalStarted event) {
// It only runs if the transaction really committed
notificationService.sendConfirmation(event.rentalId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onRollback(RentalStarted event) {
log.warn("Rental {} never made it to commit", event.rentalId());
}
}| Phase | When it runs | Typical use |
|---|---|---|
BEFORE_COMMIT |
Before the commit, still inside | Final validations |
AFTER_COMMIT (default) |
After committing | Notifications, integrations |
AFTER_ROLLBACK |
After rolling back | Logging the failure |
AFTER_COMPLETION |
After either of the two | Clean-up |
Two important warnings. In AFTER_COMMIT the transaction is already over: if the listener tries to write to the database it needs @Transactional(propagation = REQUIRES_NEW), and without that the changes are lost silently. And it is still synchronous by default, running on the same thread after the commit but before responding to the client; to avoid delaying the response, combine it with @Async (07-03).
Applied to CicloUrbana, charging for the rental now happens after the commit, with the connection already back in the pool: that change alone can multiply the application's capacity.
- Transactions and
LazyInitializationException
LazyInitializationExceptionWe close the circle opened in 04-04. With open-in-view: false (04-02), the persistence context dies with the service's transaction. Anything that leaves there is detached.
// WRONG: the entity leaves the service with lazy relationships unloaded
@Transactional(readOnly = true)
public Station get(Long id) {
return stationRepository.findById(id).orElseThrow();
}
// The controller serialises and touches getBikes() -> LazyInitializationException// RIGHT: the mapping to a DTO happens INSIDE the transaction
@Transactional(readOnly = true)
public StationDetailResponse getDetail(Long id) {
Station station = stationRepository.findWithBikes(id)
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
return mapper.toDetail(station); // here the transaction is still open
}This turns the rule from 03-05 —"the service returns DTOs, not entities"— from a good practice into a technical requirement. The module's three decisions fit together here: LAZY on every association (04-04), open-in-view: false (04-02) and mapping inside the transaction. Together they guarantee that no query fires by accident during serialisation.
Common Mistakes and Tips
Calling a @Transactional method from the same class. The annotation is ignored with no warning whatsoever. Extract to another bean.
Putting @Transactional on private or final methods. They are not intercepted. Only public methods in non-final classes.
Catching an exception and not rethrowing it. The commit happens anyway, or UnexpectedRollbackException appears. Rethrow as a domain exception.
Expecting a rollback with checked exceptions. By default there is none. In CicloUrbana it is not a problem because everything inherits from RuntimeException.
Making external calls inside the transaction. It holds a connection for the whole network call. Use @TransactionalEventListener(AFTER_COMMIT).
Overusing REQUIRES_NEW. It consumes a second connection while the first is suspended; under concurrency it drains the pool and produces deadlocks.
Raising isolation "to be safe". It increases locking and serialisation errors. READ_COMMITTED plus @Version covers almost everything.
Tip: @Transactional(readOnly = true) on the service class. It optimises every read and makes writing a conscious decision.
Tip: short transactions. Every millisecond of transaction is a millisecond of borrowed connection and locked rows. Validate before opening it and notify after closing it.
Tip: check that the transaction is actually active. With logging.level.org.springframework.transaction: DEBUG you will see the Creating new transaction and Initiating transaction commit messages; if they do not appear where you expected, it is almost certainly trap 1.
Exercises
Exercise 1: finishing a rental
Implement RentalService.finish(Long rentalId, FinishRentalRequest request). It must: verify that the rental exists and is in progress; check that the destination station has space (if not, StationFullException); compute the amount with FareCalculator; mark the bike as AVAILABLE at the destination station; close the rental; and notify the user only if everything committed. Justify each transactional decision.
Exercise 2: find four transactional faults
@Service
public class MaintenanceService {
@Transactional
public void nightlyReview() {
List<Bike> bikes = bikeRepository.findByBatteryLevelLessThan(20);
for (Bike bike : bikes) {
processBike(bike);
}
externalService.notifyWorkshop(bikes.size());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void processBike(Bike bike) {
try {
bike.setStatus(BikeStatus.MAINTENANCE);
bikeRepository.save(bike);
} catch (Exception e) {
log.error("Error", e);
}
}
}Exercise 3: choose the type of lock
For each CicloUrbana operation, decide between optimistic locking, pessimistic locking or none, and justify it.
- Editing a station's name from the council dashboard.
- Renting a station's last available bike at peak time.
- Querying the public station listing.
- Deducting balance from a user's wallet when a rental finishes.
Solutions
Solution 1.
@Service
@Transactional(readOnly = true)
public class RentalService {
@Transactional // overrides readOnly: this method writes
public RentalResponse finish(Long rentalId, FinishRentalRequest request) {
Rental rental = rentalRepository.findById(rentalId)
.orElseThrow(() -> new ResourceNotFoundException("Rental", rentalId));
if (rental.getEndedAt() != null) {
throw new ResourceConflictException("Rental " + rentalId + " has already finished");
}
Station destination = stationRepository.findById(request.destinationStationId())
.orElseThrow(() -> new ResourceNotFoundException(
"Station", request.destinationStationId()));
if (bikeRepository.countByStationId(destination.getId()) >= destination.getCapacity()) {
throw new StationFullException(destination.getId());
}
Instant endedAt = Instant.now();
BigDecimal totalAmount = fareSelector.forUser(rental.getUser())
.calculate(Duration.between(rental.getStartedAt(), endedAt));
Bike bike = rental.getBike();
bike.setStatus(BikeStatus.AVAILABLE);
destination.addBike(bike); // syncs both sides (04-04)
rental.setEndedAt(endedAt);
rental.setDestinationStation(destination);
rental.setTotalAmount(totalAmount);
rental.setStatus(RentalStatus.FINISHED);
// no save(): the three entities are managed (dirty checking)
events.publishEvent(new RentalFinished(rental.getId(), totalAmount, endedAt));
return mapper.toResponse(rental);
}
}@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyFinish(RentalFinished event) {
notificationService.sendSummary(event.rentalId(), event.totalAmount());
}The transactional decisions, one by one:
@TransactionalwithoutreadOnly, because it writes to three entities: the class declaresreadOnly = trueand this method overrides it, so writing is an explicit decision.- Everything in a single transaction. Closing the rental, releasing the bike and assigning it to the station must happen together; if the rental
UPDATEfailed after changing the bike, the bike would be leftAVAILABLEwithout being parked anywhere. - No
save(): the three entities are managed and dirty checking generates theUPDATEs at commit. - The exceptions are
CicloUrbanaExceptions, unchecked, so they cause a rollback and the@RestControllerAdvicefrom 03-06 turns them into409or404with aProblemDetail. - The notification goes in
AFTER_COMMIT: sending it inside would hold the connection during the call to the mail service and could announce a rental that is later undone. - The mapping to a DTO happens inside the transaction, a requirement with
open-in-view: false. And no pessimistic lock, because@VersiononRentalis enough: two users do not finish the same rental at once.
Solution 2. The four faults:
1. Self-invocation (the most serious). processBike(bike) is called on this, without going through the proxy, so REQUIRES_NEW is ignored completely and everything runs in the outer transaction.
2. @Transactional on a private method. It cannot be intercepted: even if the self-invocation were fixed, it would still not work. It must be public and live in another bean.
3. The exception is caught and not rethrown. If the save fails, it is logged and the loop continues: the outer transaction commits and bikes that were not processed are counted as processed. And if the failure had marked the transaction for rollback, the final commit would give UnexpectedRollbackException.
4. An external call inside the transaction. externalService.notifyWorkshop(...) holds the connection for the whole network call; in a nightly review over hundreds of bikes, the transaction can last minutes with a connection locked up.
A fifth, design-level problem: loading every bike to modify them one by one is unnecessary, since a @Modifying query (04-06) would do it in a single statement. Corrected version:
@Service
public class MaintenanceService {
private final BikeProcessor processor; // another bean: it goes through the proxy
private final ApplicationEventPublisher events;
public void nightlyReview() { // NO @Transactional: it only orchestrates
List<Long> ids = bikeRepository.findAvailableIdsWithLowBattery(20);
int processed = 0;
for (Long id : ids) {
try {
processor.markForMaintenance(id); // an independent transaction
processed++;
} catch (DataAccessException e) {
log.error("Could not process bike {}", id, e);
}
}
events.publishEvent(new NightlyReviewCompleted(processed));
}
}
@Service
public class BikeProcessor {
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5)
public void markForMaintenance(Long bikeId) {
Bike bike = bikeRepository.findById(bikeId)
.orElseThrow(() -> new ResourceNotFoundException("Bike", bikeId));
bike.setStatus(BikeStatus.MAINTENANCE);
// no save(): dirty checking
}
}nightlyReview is no longer transactional: it only orchestrates. Each bike is processed in its own transaction, an individual failure does not drag the rest down, and the notification to the workshop travels in an event handled outside any transaction.
Solution 3.
- Optimistic (
@Version). Conflicts are rare —two administrators editing the same station at once— and a409with "reload and try again" is a perfectly acceptable response. Locking the row would penalise every concurrent read for no real gain. - Pessimistic (
PESSIMISTIC_WRITEwith atimeout). This is the case of real contention: at peak time, several citizens compete for the same scarce resource. With optimistic locking, all but one get an unnecessary409when the system could have assigned them another bike. TheSELECT ... FOR UPDATEserialises access and each user gets either a bike or an honest message that none are left. - None. It is a pure read:
@Transactional(readOnly = true)and no locking. Any lock here would be pure cost. - Pessimistic. A wallet balance is the canonical example of a lost update: reading €12.50, subtracting €1.75 and writing €10.75 from two concurrent transactions makes one of the deductions vanish. Optimistic locking would detect it, but failing a charge that has already been made is worse than waiting a few milliseconds. Better still is to avoid the prior read with an atomic
UPDATE—update Wallet w set w.balance = w.balance - :amount where w.id = :id and w.balance >= :amount—, which resolves the race without locking anything.
Conclusion
You now know what @Transactional really does, and it was a great deal more than the annotation lets on. You understand the four ACID properties over the concrete case of starting a rental in Ribalta, where marking the bike and creating the record must happen together or not at all, and you have seen that the most dangerous inconsistent state is the one that produces no error. You know the annotation belongs on the service layer —not on the controller, where it would hold the connection during serialisation, nor on the repository, whose methods are too small to be a use case— and you use the pattern of readOnly = true on the class with an explicit @Transactional on the methods that write, so that writing is always a conscious decision.
You know the AOP proxy mechanism and, with it, the two traps that make the annotation do absolutely nothing: self-invocation, because a call through this does not cross the proxy, and non-public methods, which CGLIB cannot intercept. Both fail silently, and their solution —extracting to another bean— almost always improves the design too. You handle the seven values of propagation knowing that REQUIRED covers 95% of cases, that REQUIRES_NEW is the right answer for an audit trail that must survive a rollback but consumes a second connection that can drain the pool, and that NESTED is not viable with JpaTransactionManager. You can place the four isolation levels against the three concurrency phenomena, you know that PostgreSQL uses READ_COMMITTED by default and does not implement dirty reads, and you are clear that raising the level "to be safe" buys locks, not correctness.
You have mastered the rollback rule: by default only on unchecked exceptions, something that gives no trouble in CicloUrbana because the entire 03-06 hierarchy inherits from RuntimeException; and you recognise the classic mistake of catching the exception and losing the rollback, along with its baffling variant, UnexpectedRollbackException. You know when Hibernate flushes and why dirty checking makes calling save() on a managed entity unnecessary, as well as the memory cost that readOnly = true avoids. You tell optimistic locking from pessimistic and know how to choose: @Version by default, SELECT ... FOR UPDATE with a timeout in the race for the last bike at "Main Square". You use TransactionTemplate when the annotation cannot reach —one transaction per row when importing the station catalogue— and you have moved charging and notifications outside the transaction with @TransactionalEventListener(AFTER_COMMIT), returning the connection to the pool before doing anything slow. In distributed systems this atomicity stops being available and you have to fall back on patterns such as the saga, which we will see in 07-05.
One loose end remains, carried over since 04-02: the schema. ddl-auto: update is still creating tables on its own, nobody knows exactly what SQL has been run against the database and that cannot reach production. Lesson 04-08, Schema Migrations with Flyway, closes the module: we will see why the schema must be versioned like code, the naming convention for scripts and the flyway_schema_history table with its checksums, we will write CicloUrbana's complete initial schema in PostgreSQL SQL —consistent with the entities and relationships of 04-03 and 04-04— along with the data migration that retires module 1's DemoStationLoader, and we will learn the expand/contract pattern for renaming a column without stopping the service.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
