@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

  1. What a transaction is: ACID in CicloUrbana
  2. Declarative management with @Transactional
  3. Where it goes and why
  4. How it works underneath: the AOP proxy
  5. Trap 1: self-invocation
  6. Trap 2: non-public methods
  7. propagation: the seven values
  8. isolation: the four levels
  9. readOnly, timeout and the remaining attributes
  10. The rollback rule
  11. The persistence context and flushing
  12. Pessimistic versus optimistic locking
  13. TransactionTemplate: programmatic management
  14. Transactional events
  15. Transactions and LazyInitializationException
  16. Common Mistakes and Tips
  17. Exercises

  1. 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.

  1. Declarative management with @Transactional

Spring 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.

@Transactional
public void writingMethod() { /* ... */ }

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.

  1. 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.

  1. 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.

  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.

  1. 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 reliably

With 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.

  1. propagation: the seven values

propagation 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.

  1. isolation: the four levels

isolation 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.

  1. readOnly, timeout and the remaining attributes

readOnly = 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

  1. 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.

  1. 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.

  1. 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.

  1. TransactionTemplate: programmatic management

When 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.

  1. 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.

  1. Transactions and LazyInitializationException

We 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.

  1. Editing a station's name from the council dashboard.
  2. Renting a station's last available bike at peak time.
  3. Querying the public station listing.
  4. 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:

  • @Transactional without readOnly, because it writes to three entities: the class declares readOnly = true and 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 UPDATE failed after changing the bike, the bike would be left AVAILABLE without being parked anywhere.
  • No save(): the three entities are managed and dirty checking generates the UPDATEs at commit.
  • The exceptions are CicloUrbanaExceptions, unchecked, so they cause a rollback and the @RestControllerAdvice from 03-06 turns them into 409 or 404 with a ProblemDetail.
  • 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 @Version on Rental is 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.

  1. Optimistic (@Version). Conflicts are rare —two administrators editing the same station at once— and a 409 with "reload and try again" is a perfectly acceptable response. Locking the row would penalise every concurrent read for no real gain.
  2. Pessimistic (PESSIMISTIC_WRITE with a timeout). 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 unnecessary 409 when the system could have assigned them another bike. The SELECT ... FOR UPDATE serialises access and each user gets either a bike or an honest message that none are left.
  3. None. It is a pure read: @Transactional(readOnly = true) and no locking. Any lock here would be pure cost.
  4. 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved