Use transaction boundaries, locking, fetch plans, and an outbox to make Spring services correct under load. This spring boot training guide targets production-grade java backend development.
Spring Boot Training: Reliable Transaction Boundaries with JPA
Spring Boot Training: Put Transaction Boundaries Around a Business Decision
In spring boot training, a useful rule is: a transaction should cover one business decision, not one repository call. Start by making the application service own the boundary and keep database mutations behind that service. For example, reserving inventory must read availability and decrement stock in the same transaction; two separate repository transactions can both observe the same available quantity before either update commits.
@Service
class OrderPlacementService {
private final InventoryReservation inventoryReservation;
private final OrderRepository orders;
OrderPlacementService(InventoryReservation inventoryReservation,
OrderRepository orders) {
this.inventoryReservation = inventoryReservation;
this.orders = orders;
}
@Transactional
public UUID place(PlaceOrder command) {
inventoryReservation.reserve(command.sku(), command.quantity());
Order order = Order.create(command.customerId(), command.sku(), command.quantity());
orders.save(order);
return order.getId();
}
}
@Service
class InventoryReservation {
@Transactional(propagation = Propagation.MANDATORY)
public void reserve(String sku, int quantity) {
// Load, validate, and update inventory in the caller's transaction.
}
}Verify the boundary rather than assuming that an annotation worked: enable transaction logs with logging.level.org.springframework.transaction.interceptor=TRACE, then invoke the HTTP endpoint once and confirm one transaction interceptor entry for the use case. A frequent production bug is self-invocation: calling this.reserve(...) bypasses Spring's proxy, so its @Transactional metadata is not applied. Moving the method to a separate bean, as above, is usually clearer than relying on self-proxy injection.
Disable Open Session in View for API services with spring.jpa.open-in-view=false and make every endpoint's fetch requirements explicit inside the service transaction. This turns accidental lazy loading during JSON serialization into an immediate failure instead of allowing a controller to issue unbounded SQL after business logic has completed. It is especially important in java training projects because a response DTO mapper that touches order.getLines() can otherwise hide an N+1 query behind a successful integration test.
Hibernate ORM and Spring Data JPA: Model Concurrent Updates Explicitly
With hibernate orm and spring data jpa, add an optimistic version to aggregates that can be edited concurrently. Hibernate includes the prior version in the generated UPDATE predicate; if another transaction has committed first, the affected-row count is zero and Hibernate raises an optimistic locking exception instead of silently overwriting the other write.
@Entity
class InventoryItem {
@Id
private String sku;
@Version
private long version;
private int available;
public void reserve(int quantity) {
if (quantity <= 0 || quantity > available) {
throw new InsufficientStockException(sku);
}
available -= quantity;
}
}
interface InventoryItemRepository extends JpaRepository<InventoryItem, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select i from InventoryItem i where i.sku = :sku")
Optional<InventoryItem> findForUpdate(String sku);
}Choose the lock from measured contention. For ordinary edits, use @Version and return HTTP 409 on ObjectOptimisticLockingFailureException, allowing a client or command handler to reload and retry. For a hot, finite resource such as a single-seat reservation, load it with findForUpdate and set a bounded lock timeout using jakarta.persistence.lock.timeout; otherwise a burst can exhaust request threads while they wait on the database lock.
An experienced edge case: a version field only protects the entity whose version is checked. If an invariant spans child rows, such as “an order's line total cannot exceed its credit limit,” updating independent child entities may not conflict. Put the invariant-changing operation on a versioned aggregate root, explicitly update that root in the same transaction, or use a database constraint. Do not implement a blind retry inside the same failed transaction: once Hibernate marks it rollback-only, retry from a new transaction at the command boundary.
Java Backend Development: Measure and Remove N+1 Queries
For java backend development, treat query count as a testable budget. In a non-production environment, enable Hibernate statistics with spring.jpa.properties.hibernate.generate_statistics=true and logging.level.org.hibernate.stat=DEBUG. Clear statistics before one service call, then inspect sessionFactory.getStatistics().getPrepareStatementCount(). A list endpoint returning 50 orders should not execute 51 statements merely because a DTO mapper traverses each order's customer association.
public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = { "customer", "shippingAddress" })
@Query("select o from Order o where o.status = :status order by o.createdAt desc")
List<Order> findRecentByStatus(OrderStatus status);
}
@Test
void recentOrdersStayWithinTwoStatements() {
statistics.clear();
orderQueryService.recentOrders();
assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(2);
}Use the before-and-after loop: capture statement count and database execution time with Hibernate statistics, then inspect actual SQL using p6spy or datasource-proxy before adding an @EntityGraph or a DTO projection. Prefer a projection when the endpoint needs only columns for a table view; it avoids hydrating entities, dirty-check snapshots, and lazy proxies. For example, a JPQL select new ...OrderRow(o.id, c.name, o.total) is often a better read model than loading an aggregate solely to serialize three fields.
Do not solve every N+1 by fetch-joining a collection. A fetch join over Order.lines duplicates root rows, and applying pagination can force in-memory paging or return unstable pages. Set spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true so this becomes visible in tests. For a paged parent list, fetch the page of order IDs first, then load children in a second query with where order.id in :ids, preserving page semantics and bounding statement count.
Java Microservices: Commit Domain State and Events with an Outbox
In java microservices, publishing directly to a broker after orders.save(order) creates a dual-write failure window: the database commit can succeed after the broker call fails, or a broker event can be visible even though the database transaction later rolls back. Write an outbox record in the same local transaction as the aggregate mutation, then let a separate relay publish committed rows.
create table outbox_event (
id uuid primary key,
aggregate_type varchar(80) not null,
aggregate_id varchar(80) not null,
event_type varchar(120) not null,
payload jsonb not null,
occurred_at timestamptz not null,
published_at timestamptz null
);
create index outbox_unpublished_idx
on outbox_event (occurred_at) where published_at is null;@Transactional
public UUID place(PlaceOrder command) {
Order order = Order.create(command.customerId(), command.sku(), command.quantity());
orders.save(order);
OutboxEvent event = OutboxEvent.of(
UUID.randomUUID(), "Order", order.getId().toString(),
"order.placed", json.writeValueAsString(OrderPlaced.from(order)));
outboxEvents.save(event);
return order.getId();
}Implement the relay with a batch claim query such as select id from outbox_event where published_at is null order by occurred_at for update skip locked limit 100. SKIP LOCKED lets several relay workers take distinct rows without waiting behind one slow publish. The delivery guarantee is normally at-least-once: if the process publishes successfully and dies before setting published_at, it will publish again. Include the outbox UUID as the broker message key and make consumers persist processed IDs under a unique constraint; broker-side retries alone do not make a non-idempotent consumer safe.
Java Course Testing: Reproduce Database Semantics, Not Just Repository Calls
A serious java course should run persistence tests against the production database family, not only an in-memory substitute. PostgreSQL-specific JSON behavior, FOR UPDATE locking, timestamp precision, and partial indexes can all differ from H2. Use Testcontainers and wire the container URL dynamically so the test executes the same migration scripts and SQL dialect used by the deployed service.
@Testcontainers
@SpringBootTest
class OrderPersistenceIT {
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}For a java fullstack course project, add a concurrency test that launches two independent transactions against the same inventory row and assert one succeeds while the other receives a conflict or insufficient-stock result. Use two threads, a CountDownLatch to align their reads, and separate transaction boundaries through a proxied service; a test method annotated with @Transactional would otherwise place both operations in one transaction and fail to reproduce the race.
Run ./mvnw test or ./gradlew test with SQL migrations enabled in CI, and fail the build when a migration cannot be applied to a fresh container. This catches a subtle mismatch that mocks cannot detect: a repository method may pass unit tests while its generated query references an index, column type, or lock mode absent from the actual schema.
Related Course
Related YTUSEM Program
Frequently Asked Questions
How should spring boot training explain @Transactional self-invocation?
Explain it with a proxy-level test: put an annotated method in the same class, call it through this.method(), and enable org.springframework.transaction.interceptor TRACE logging. There will be no second interceptor entry because the call never crosses the Spring proxy. Move the method to another bean or invoke it from an external caller; do not assume the annotation alone starts a nested transaction.
When should spring data jpa use optimistic locking instead of PESSIMISTIC_WRITE?
Use @Version when collisions are uncommon and a rejected update can be retried or returned as HTTP 409. Use PESSIMISTIC_WRITE for short, high-contention critical sections where allowing two requests to read the same state is unacceptable. Set a database lock timeout, measure lock waits in database monitoring, and keep network calls outside the locked transaction.
How can a java backend development team detect N+1 queries automatically?
Enable Hibernate statistics in integration tests, call statistics.clear() before the service method, and assert a maximum prepared-statement count for representative data volumes. Pair the assertion with p6spy or datasource-proxy SQL logs when it fails, because the count identifies the regression while the logged repeated SELECT identifies the association causing it.
Why does a java microservices outbox still need idempotent consumers?
A relay can crash after broker acknowledgement but before recording published_at, so the same outbox row is delivered again on restart. Put the outbox event UUID in each message, store processed IDs with a unique database constraint in the consumer transaction, and treat a duplicate-key result as an already-applied message rather than running the side effect twice.
AI / LLM Discovery
This article is part of Opendart Akademi's Java training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.


