Skip to main content
ClaudeWave
Skill262 estrellas del repoactualizado 5d ago

transactional-patterns

This Claude Code skill teaches Spring Boot transactional patterns, covering proper placement of @Transactional annotations on service methods, propagation modes like REQUIRED and REQUIRES_NEW for controlling transaction scope, and common pitfalls such as self-invocation bypassing Spring proxies. Use it when building multi-step database operations that require atomic behavior or when coordinating writes across multiple repositories within a single transaction.

Instalar en Claude Code
Copiar
git clone --depth 1 https://github.com/rrezartprebreza/spring-boot-skills /tmp/transactional-patterns && cp -r /tmp/transactional-patterns/skills/spring-boot-4/transactional-patterns ~/.claude/skills/transactional-patterns
Después abre una sesión nueva de Claude Code; el skill carga automáticamente.

SKILL.md

# Transactional Patterns

## Basic Rules

- `@Transactional` belongs on **service methods**, never controllers or repositories
- Default propagation is `REQUIRED` — joins existing transaction or creates one
- Always use on methods that write to the DB or coordinate multiple writes
- `@Transactional(readOnly = true)` on all read-only service methods — enables optimizations

```java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default for all methods in this service
public class OrderService {

    @Transactional // overrides readOnly for writes
    public Order createOrder(CreateOrderRequest request) {
        inventoryService.reserve(request.items()); // participates in same TX
        return orderRepository.save(Order.from(request));
    }

    public Optional<Order> findById(UUID id) {
        return orderRepository.findById(id); // readOnly = true inherited
    }
}
```

## Propagation

| Propagation | Behavior |
|-------------|----------|
| `REQUIRED` (default) | Join existing TX or create new |
| `REQUIRES_NEW` | Always create new TX, suspend existing |
| `SUPPORTS` | Join if exists, proceed without TX if not |
| `NOT_SUPPORTED` | Always run without TX |
| `MANDATORY` | Must have existing TX, throw if not |
| `NEVER` | Must NOT have TX, throw if one exists |

```java
// REQUIRES_NEW — for audit logging that must survive rollback
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
    auditRepository.save(event); // commits independently of parent TX
}

// Order TX rolls back, audit log still saved
@Transactional
public void processOrder(Order order) {
    auditService.logAuditEvent(new AuditEvent("ORDER_START", order.getId()));
    try {
        // ... process, may throw
    } catch (Exception e) {
        auditService.logAuditEvent(new AuditEvent("ORDER_FAILED", order.getId()));
        throw e; // parent TX rolls back, audit TX already committed
    }
}
```

## Self-Invocation Pitfall

```java
// ❌ BROKEN — self-invocation bypasses Spring proxy, @Transactional ignored
@Service
public class OrderService {
    @Transactional
    public void processAll(List<UUID> ids) {
        ids.forEach(id -> this.processSingle(id)); // bypasses proxy!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processSingle(UUID id) { ... } // never creates new TX
}

// ✅ FIX — inject self or extract to separate bean
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderProcessor orderProcessor; // separate bean

    @Transactional
    public void processAll(List<UUID> ids) {
        ids.forEach(id -> orderProcessor.processSingle(id)); // goes through proxy
    }
}
```

## Handling Exceptions

```java
// @Transactional rolls back on RuntimeException by default
// For checked exceptions, explicitly declare rollbackFor

@Transactional(rollbackFor = InsufficientInventoryException.class) // checked exception
public Order createOrder(CreateOrderRequest request) throws InsufficientInventoryException {
    ...
}

// noRollbackFor — for non-fatal exceptions you want to commit anyway
@Transactional(noRollbackFor = OptimisticLockException.class)
public void updateWithRetry(UUID id) { ... }
```

## Optimistic Locking

```java
@Entity
public class Order {
    @Version
    private Long version; // Hibernate handles conflicts automatically
}

// Handles concurrent updates
@Transactional
public Order updateStatus(UUID id, OrderStatus newStatus) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.updateStatus(newStatus); // if another TX modified it, throws ObjectOptimisticLockingFailureException
    return orderRepository.save(order);
}
```

## Retrying Transient Failures

Retry support ships in core Spring Framework (`org.springframework.resilience.annotation`) — no
Spring Retry dependency. Enable once with `@EnableResilientMethods`, then retry transient failures
such as optimistic-lock conflicts:

```java
@Configuration
@EnableResilientMethods
public class ResilienceConfig {}

@Service
@RequiredArgsConstructor
public class OrderStatusFacade {

    private final OrderService orderService; // separate bean — retry must wrap the TX

    @Retryable(includes = ObjectOptimisticLockingFailureException.class,
               maxRetries = 3, delay = 50, jitter = 25)
    public Order updateStatus(UUID id, OrderStatus newStatus) {
        return orderService.updateStatus(id, newStatus); // fresh @Transactional per attempt
    }
}
```

Put `@Retryable` on a method that *calls* the `@Transactional` method on another bean — each attempt
needs a fresh transaction. Retrying inside the failed transaction re-runs code in a TX already marked
rollback-only. For hot write paths, `@ConcurrencyLimit(10)` (same package) caps concurrent invocations
instead of letting contention turn into retry storms.

## Distributed Transactions (Saga Pattern)

For multi-service operations, use the Saga pattern instead of distributed TX:

```java
@Service
@RequiredArgsConstructor
public class OrderSaga {

    @Transactional
    public void execute(CreateOrderRequest request) {
        Order order = orderRepository.save(Order.create(request));
        try {
            inventoryClient.reserve(request.items());       // step 1
            paymentClient.charge(order.getId(), request.total()); // step 2
            order.confirm();
            orderRepository.save(order);
        } catch (PaymentException e) {
            inventoryClient.release(request.items()); // compensate step 1
            order.fail("Payment failed");
            orderRepository.save(order);
            throw e;
        }
    }
}
```

## Side Effects After Commit

Never fire an external side effect (email, Kafka publish, webhook, cache warm) inside the transaction —
if the TX rolls back, you've already sent it. Bind the side effect to the commit instead:

```java
// Publisher — inside the TX
@Transactional
public Order place(UUID id) {