Spring Backend · Lesson 5

Spring Service Layers and Transaction Boundaries Explained

Design Spring application services and transactions that preserve local order invariants without hiding proxy, rollback, thread, remote-call, or side-effect boundaries.

Quick answer

Put a use case in a Spring application service, give that service a short local transaction when it changes one consistent set of durable state, and keep adapters thin. @Transactional is not a magic property of a Java method. In the usual proxy-based Spring model, an external call must pass through a Spring proxy before transaction interception can begin. That makes bean boundaries, method visibility, self-invocation, propagation, threads, remote calls, and error handling part of the design rather than annotation trivia.

For the flash-sale order service, the transaction should atomically create or reuse the idempotent order record and reserve inventory using the database concurrency rule. It should not wait for a payment gateway, send an email, publish an irreversible remote message, or serialize a large response while holding a connection and locks. After commit, a durable outbox or an explicit state machine can request external payment. If that later work fails, recovery is a business workflow, not evidence that the local reservation never happened.

This article treats Spring Boot 4.1 as the documented mainline and uses Java 17 with imperative Spring MVC transactions. Spring Boot 3.5 applications can use the same boundary design, but must verify the transaction manager, proxy configuration, and framework-specific defaults in their selected versions. Version labels do not make a transaction distributed or turn a checked exception into an automatic rollback.

Shared order-service incident stage

During the shared incident, the controller has already parsed a flash-sale POST /orders, validation has rejected malformed quantities, and authorization has resolved the customer and tenant. The next stage is the application service: it must decide whether the idempotency key represents a prior completed command, whether the SKU may be reserved, and which local state transition is durable. Pool wait is already growing because concurrent requests contend for inventory rows. Keeping this stage compact determines whether that pressure remains bounded.

The order service enters a transaction, reads or conditionally updates inventory, records the reservation and pending order, and writes an outbox item in the same local unit of work. It then commits. Only after the commit may a worker call the payment provider. That timeline is deliberate: a retrying caller can receive the already-recorded result, responders can distinguish an inventory conflict from a payment delay, and a lost process can resume the outbox from durable evidence. A local transaction does not include the HTTP response, the browser retry, or the payment provider.

When responders find long transaction duration, pool wait, and lock waits together, they should inspect the code path for remote waits, slow queries, excessive work inside the transaction, and retry storms. Increasing the pool alone can create more active lock contenders. The safe immediate action may be to reduce admission for optional traffic, return a documented retryable outcome where the command has not started, and preserve the inventory/idempotency invariant. The service layer is where that operational policy becomes an executable sequence.

Core mechanism and evidence boundary

An application service coordinates a use case; it is not merely a repository wrapper. Its inputs are already-validated transport data plus trusted identity, and its outputs are domain results safe for an adapter to turn into HTTP. It owns orchestration such as “reserve stock and create an order exactly once for this key.” Repositories own data access details, controllers own protocol conversion, and an outbox dispatcher owns external delivery. Keeping those responsibilities distinct lets a queue consumer or repair job call the same use case without smuggling HTTP objects into the domain.

Spring commonly implements declarative transaction advice with an AOP proxy around a Spring bean. A caller that obtains the bean from the container and invokes its transactional method crosses that proxy; the interceptor can open, join, suspend, commit, or roll back a transaction around the invocation. A direct call from one method to another method on the same object is self-invocation. It bypasses the proxy, so the inner method’s @Transactional settings are not newly intercepted in the ordinary proxy model. Extracting the inner operation to another bean, changing the transaction boundary at the outer service, or using a deliberately chosen programmatic API are clearer than relying on accidental behavior.

The default rollback rule matters. For standard declarative Spring transactions, an unchecked RuntimeException or Error marks rollback by default; a checked exception does not by default. That is a default, not a statement of business correctness. A checked exception representing an unsuccessful reservation may need rollbackFor, while an unchecked exception after a deliberately persisted audit fact may require a different design. Do not catch an exception, log it, and continue to return success unless the resulting committed state satisfies an explicit contract. Conversely, do not use broad rollback rules to conceal an outcome that must be durably recorded for reconciliation.

Propagation describes the relationship to a transaction that is already associated with the current execution. REQUIRED joins one when present or starts a new one otherwise, which is appropriate for closely coupled local order writes. REQUIRES_NEW suspends an existing transaction and starts another; it can be useful for a carefully bounded independent record, but it also consumes another connection and can leave that record committed when the outer order fails. NESTED depends on savepoint support and is not interchangeable with a separate durable transaction. MANDATORY, SUPPORTS, NOT_SUPPORTED, and NEVER express further constraints; select them from an invariant, not from a desire to silence an exception.

Thread binding is another boundary. A typical imperative transaction manager binds resources to the current execution thread while the invocation runs. A new executor task, a reactive callback, or an asynchronous method does not automatically inherit that transaction. Nor does @Transactional automatically propagate to new threads or remote services. Pass an immutable command and remaining deadline to asynchronous work; if it needs its own database work, give it an explicit transaction and idempotency contract. A remote payment request needs a protocol that tolerates retries and partial completion, not a thread-local database connection.

Evidence should show both correctness and capacity without leaking customer data. Record transaction duration, pool acquisition wait, lock wait, reservation conflict count, idempotency replay count, outbox backlog, and outcome families with bounded tags. Correlate a protected trace or log event with an opaque request identifier and deployment version. Do not label metrics with an order ID, email, idempotency key, SQL string, or exception message. A successful commit is local evidence; it is not proof that a remote payment has settled or that a client received the response.

Minimal reproducible Spring example

This Java 17 sketch makes the external-bean and after-commit boundaries visible. In Spring Boot 4.1, use the exact transaction manager selected by the application configuration. A Spring Boot 3.5 service should keep the same local transaction shape while checking its compatible Spring Framework APIs and driver behavior.

package com.example.orders;

import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PlaceOrderService {
    private final OrderAttemptTransaction attemptTransaction;
    private final ReplayLookup replayLookup;

    public PlaceOrderService(OrderAttemptTransaction attemptTransaction, ReplayLookup replayLookup) {
        this.attemptTransaction = attemptTransaction;
        this.replayLookup = replayLookup;
    }

    public PlacedOrder place(PlaceOrderCommand command) {
        try {
            return attemptTransaction.attempt(command);
        } catch (IdempotencyKeyConflictException idempotencyConflict) {
            // The proxied attempt invocation has already ended and rolled back.
            return replayLookup.loadWinner(command);
        }
    }

    public record PlaceOrderCommand(
            UUID customerId, String idempotencyKey, String sku, int quantity) {
        String commandFingerprint() {
            return CommandFingerprint.v1(customerId, sku, quantity);
        }
    }
}

@Service
class OrderAttemptTransaction {
    private final InventoryRepository inventory;
    private final OrderRepository orders;
    private final OutboxRepository outbox;

    OrderAttemptTransaction(InventoryRepository inventory, OrderRepository orders, OutboxRepository outbox) {
        this.inventory = inventory;
        this.orders = orders;
        this.outbox = outbox;
    }

    @Transactional(timeout = 3)
    public PlacedOrder attempt(PlaceOrderService.PlaceOrderCommand command) {
        var existing = orders.findByCustomerAndKey(
                command.customerId(), command.idempotencyKey());
        if (existing.isPresent()) {
            return ReplayDecision.requireSameCommand(existing.get(), command);
        }
        if (!inventory.reserveIfAvailable(command.sku(), command.quantity())) {
            throw new InventoryUnavailableException(command.sku());
        }
        PlacedOrder order = orders.insertPending(command, command.commandFingerprint());
        outbox.record("payment-requested", order.orderId());
        orders.flush(); // Surface a uniqueness conflict inside this invocation.
        return order;
    }
}

@Service
class ReplayLookup {
    private final OrderRepository orders;

    ReplayLookup(OrderRepository orders) { this.orders = orders; }

    @Transactional(readOnly = true, timeout = 1)
    public PlacedOrder loadWinner(PlaceOrderService.PlaceOrderCommand command) {
        PlacedOrder winner = orders.findByCustomerAndKey(
                command.customerId(), command.idempotencyKey()).orElseThrow();
        return ReplayDecision.requireSameCommand(winner, command);
    }
}

final class ReplayDecision {
    static PlacedOrder requireSameCommand(
            PlacedOrder winner, PlaceOrderService.PlaceOrderCommand command) {
        if (!winner.commandFingerprint().equals(command.commandFingerprint())) {
            throw new IdempotencyKeyReuseException(command.idempotencyKey());
        }
        return winner;
    }
}

The nontransactional PlaceOrderService is the executable retry boundary. Its call to the container-managed OrderAttemptTransaction crosses a proxy. The initial lookup avoids needless writes but is not a race guarantee; a unique (customer_id, idempotency_key) conflict can still escape that invocation. The repository translates only that named constraint into IdempotencyKeyConflictException. The failed transaction rolls back; a fresh transaction for the winner read can begin only after its invocation ends. Spring also discards that failed persistence context and releases its reservation and outbox writes before the catch block runs. Only then does the outer service call the separate ReplayLookup bean to reload the committed winner. Unrelated integrity failures must propagate.

The winner is reusable only when its versioned, server-computed command fingerprint matches the normalized customer, SKU, quantity, and other semantics covered by that idempotency contract. Reusing the same key for a different command raises a stable conflict instead of returning somebody else’s earlier outcome. The fingerprint is not supplied as caller authority and should not expose raw personal or payment data. An atomic database upsert can implement the same uniqueness, rollback, fresh-read, and fingerprint contract, but a check followed by an unguarded insert cannot.

The collaborating beans are deliberately non-final and their intercepted methods are public for class-based proxying; interface proxies are another explicit design. The repository’s conditional reservation still needs a database-safe predicate or concurrency control. Loading quantity and decrementing it in memory is not enough under flash-sale races, and reusing the failed transaction’s managed entities after a constraint violation is not a recovery path.

The timeout is a boundary on transaction participation, not a cancellation guarantee for every downstream effect. A timeout may mark the transaction rollback-only or surface at commit depending on the resource and timing. It does not undo an already-issued remote HTTP request, reverse a message already accepted by a broker, or stop every blocked driver operation immediately. Set short client deadlines outside the transaction, propagate cancellation where the client supports it, and design external operations for duplicate delivery and reconciliation.

Failure modes and dangerous misconceptions

“Every annotated method starts a transaction.” Only a call intercepted by the configured transaction infrastructure gets that declarative behavior. A private method, a self-invoked method, an object created with new, or a call made before the proxy is involved may not create the boundary readers expect. Test the actual bean wiring and observable database outcome rather than assuming annotation presence is proof.

“Checked errors always roll back.” The default distinguishes unchecked and checked exceptions. Model each failure deliberately, set rollback rules only where they describe the desired durable outcome, and avoid catch-and-swallow code that makes a caller believe an order succeeded. Explicit compensation is often safer than trying to encode a distributed business process in exception taxonomy.

REQUIRES_NEW is safer.” It can commit audit or notification-intent data while the outer transaction rolls back, which may be correct only if that independent fact is meaningful. It also needs another connection while the outer transaction is suspended. Under high pool wait, casual use can turn one request into multiple scarce connections and complicate recovery.

“A transaction crosses the remote payment call.” A local database transaction cannot make an external provider atomic. Holding the transaction open around a remote call lengthens locks and pool use; retrying after an ambiguous network timeout can charge twice without provider idempotency. Commit local intent, dispatch reliably, and reconcile from provider and order state.

“Async work inherits the request transaction.” It does not automatically carry thread-bound resources into a new thread. Passing a managed entity into another task can also create detached-state surprises. Pass a stable identifier and a deadline, then load fresh state in a separately designed transaction.

Security, privacy, transaction, capacity, and cost implications

Use trusted authentication data to choose the customer and tenant inside the service. Do not let a body field select another customer’s order, and re-check ownership when a command targets an existing resource. Authorization is not a transaction propagation setting: it should occur before expensive work and remain enforceable for non-HTTP callers such as repair jobs. Audit decisions should be narrowly scoped and redact payment, address, and secret data.

Transaction duration is a capacity and cost control. A long-lived transaction retains a connection, may hold locks, increases contention, and makes pool wait visible to unrelated customers. Define the smallest local state that must commit together; move remote communication and nonessential CPU work outside it. Concurrency caps, bounded queues, and caller deadlines prevent a saturated order database from turning retried work into a memory or billing incident.

Side effects require an explicit durability decision. An outbox written with the order can prove what still needs delivery; an after-commit listener without durable handoff may lose work on a process crash. Conversely, publishing a message before a local transaction commits can expose a state no reader can yet find. Choose an outbox, a broker transaction where genuinely supported, or a compensating workflow based on failure semantics, then test the crash windows.

Avoid collecting more operational data than recovery needs. Protected logs may contain a correlation ID and a safe outcome code, while metrics expose only bounded dimensions. Encrypt transport, restrict database and telemetry access, rotate credentials through configuration mechanisms, and ensure a support user cannot invoke a repair operation outside authorization and rate limits. Cost-conscious observability retains actionable samples without turning every order into a permanent trace.

Testing and production validation

Write focused service tests for idempotency decisions, authorization inputs, conflict mapping, and which exceptions are intentionally propagated. Use integration tests with the real transaction manager and database to prove the reservation predicate, unique idempotency constraint, commit, rollback, and concurrent behavior. A mock repository cannot prove that two transactions do not oversell stock. Include a test where an unchecked failure rolls back and a checked failure follows the documented rule; assert actual persisted rows, not only a thrown exception.

Test proxy behavior directly when it matters. Invoke the bean from the container, then contrast it with a deliberately self-invoked helper only if that distinction is part of the design. Test propagation with separate observable records and a bounded pool configuration. For asynchronous dispatch, simulate a crash after order commit but before payment delivery, then prove the outbox is recovered exactly according to its idempotency key. Do not claim that a local test proves a remote provider’s semantics.

In production, correlate a canary order through admission, reservation, commit, outbox dispatch, and safe response. During the flash-sale load test, graph offered work, accepted work, transaction duration, active and waiting connections, lock waits, conflicts, outbox age, and user latency. Force a slow query and a remote-payment timeout. Verify that transactions end promptly, retries do not duplicate the local command, and recovery leaves an explainable terminal or pending state.

Operations checklist

  • Put business orchestration in an application service and protocol conversion in adapters.
  • Start one short local transaction around the state that must commit together.
  • Verify proxy interception; never rely on accidental self-invocation behavior.
  • Define rollback behavior for checked and unchecked failures from the durable business outcome.
  • Choose propagation only after documenting what may commit independently.
  • Treat thread switches, executors, and remote calls as new boundaries with explicit contracts.
  • Keep payment, email, and remote messaging outside the inventory transaction.
  • Use idempotency, a durable outbox, and reconciliation for external side effects.
  • Measure transaction duration, pool wait, lock wait, conflicts, and outbox age with bounded labels.
  • Rehearse crash, timeout, and duplicate-delivery recovery before the flash sale.

Official sources

Continue the learning path

Use Spring Data JPA Query Performance Explained to make transaction duration explainable from query evidence, then configure the service safely with Spring Boot Configuration Properties, Profiles, and Secrets Explained. Return to the Spring Backend course, browse Topics, and revisit Spring Backend Engineering for the full order-service path.

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. A transactional method self-invokes another annotated method that throws a checked exception; which review identifies the real rollback boundary?

2. Payment latency holds inventory transactions open during a flash sale; which redesign preserves local evidence and bounded connection use?