DEV Community

Cover image for Why Your @Async Method Ignores @Transactional (And Leaks Internal Errors)
Anand Rathnas
Anand Rathnas

Posted on

Why Your @Async Method Ignores @Transactional (And Leaks Internal Errors)

Production bug report: "Why does the webhook error say 'Executing an update/delete query'?"

That's a JPA internal error. It should never reach users. But there it was, stored in the database and visible in the admin panel. Here's how an innocent-looking @Async method broke everything.

The Code That Looked Fine

@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {

    private final WebhookRepository webhookRepository;
    private final WebhookEventRepository webhookEventRepository;

    @Async
    public void fireEvent(Long userId, String event, Map<String, Object> payload) {
        List<WebhookEntity> webhooks = webhookRepository
            .findByUserIdAndEnabledTrue(userId);

        for (WebhookEntity webhook : webhooks) {
            WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
            deliverWebhook(webhookEvent, webhook);
        }
    }

    @Transactional
    public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
        // ... send HTTP request ...

        if (success) {
            event.setStatus(WebhookEventStatus.DELIVERED);
            webhookEventRepository.save(event);

            // Reset failure count atomically
            webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The repository method:

@Modifying
@Query("UPDATE WebhookEntity w SET w.failureCount = 0, w.modifiedTime = :now WHERE w.id = :id")
void resetFailureCount(@Param("id") Long id, @Param("now") Long now);
Enter fullscreen mode Exit fullscreen mode

Looks reasonable, right? @Async for background processing, @Transactional for database consistency, @Modifying for atomic updates.

The Error

javax.persistence.TransactionRequiredException:
Executing an update/delete query
Enter fullscreen mode Exit fullscreen mode

This was getting stored in the lastError field of webhook events. Users could see it. Security and UX nightmare.

The Root Cause: Self-Invocation Bypasses Proxies

Spring's @Transactional works through proxies. When you call a @Transactional method, you're actually calling a proxy that:

  1. Starts a transaction
  2. Calls your actual method
  3. Commits or rolls back

But here's the catch: self-invocation bypasses the proxy.

@Async
public void fireEvent(...) {
    // This runs in a new thread, outside any transaction

    for (WebhookEntity webhook : webhooks) {
        // This calls the method DIRECTLY, not through the proxy
        deliverWebhook(webhookEvent, webhook);  // @Transactional is ignored!
    }
}
Enter fullscreen mode Exit fullscreen mode

When fireEvent() calls deliverWebhook(), it's calling this.deliverWebhook() - the actual method on the instance, not the Spring proxy. The @Transactional annotation is invisible.

The @Modifying query requires an active transaction. No transaction = exception.

The Fix: TransactionTemplate

When you can't rely on @Transactional, use programmatic transaction management:

@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {

    private final WebhookRepository webhookRepository;
    private final WebhookEventRepository webhookEventRepository;
    private final TransactionTemplate transactionTemplate;  // Inject this

    @Async
    public void fireEvent(Long userId, String event, Map<String, Object> payload) {
        List<WebhookEntity> webhooks = webhookRepository.findByUserIdAndEnabledTrue(userId);

        for (WebhookEntity webhook : webhooks) {
            WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
            deliverWebhook(webhookEvent, webhook);
        }
    }

    public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
        // ... send HTTP request ...

        if (success) {
            // Wrap database operations in explicit transaction
            transactionTemplate.executeWithoutResult(status -> {
                event.setStatus(WebhookEventStatus.DELIVERED);
                webhookEventRepository.save(event);
                webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
            });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

TransactionTemplate doesn't rely on proxies. It explicitly starts and commits transactions. Works everywhere, including:

  • Self-invoked methods
  • @Async methods
  • Lambda callbacks
  • Anywhere proxy magic fails

Bonus: Sanitize Your Error Messages

Even after fixing the transaction issue, you should never leak internal errors to users. Add a sanitizer:

private String sanitizeErrorMessage(String message) {
    if (message == null) {
        return "Unknown error";
    }

    // Detect internal errors
    if (message.contains("Executing an update/delete query") ||
        message.contains("javax.persistence") ||
        message.contains("org.hibernate") ||
        message.contains("java.sql") ||
        message.contains("SQLException")) {
        log.error("Internal error during webhook delivery: {}", message);
        return "Internal server error";
    }

    // Truncate overly long messages
    if (message.length() > 500) {
        return message.substring(0, 497) + "...";
    }

    return message;
}
Enter fullscreen mode Exit fullscreen mode

Now even if something slips through, users see "Internal server error" instead of stack traces.

Testing TransactionTemplate

When mocking, configure the template to execute the lambda:

@Mock
private TransactionTemplate transactionTemplate;

@BeforeEach
void setUp() {
    // Make the mock actually execute the lambda
    lenient().doAnswer(invocation -> {
        Consumer<Object> action = invocation.getArgument(0);
        action.accept(null);
        return null;
    }).when(transactionTemplate).executeWithoutResult(any());
}
Enter fullscreen mode Exit fullscreen mode

Alternative Approaches

1. Inject Self Reference

@Service
public class WebhookService {
    @Lazy
    @Autowired
    private WebhookService self;  // Inject the proxy

    @Async
    public void fireEvent(...) {
        self.deliverWebhook(event, webhook);  // Goes through proxy
    }

    @Transactional
    public void deliverWebhook(...) { }
}
Enter fullscreen mode Exit fullscreen mode

Works, but feels hacky.

2. Separate Into Two Services

@Service
public class WebhookFiringService {
    private final WebhookDeliveryService deliveryService;

    @Async
    public void fireEvent(...) {
        deliveryService.deliverWebhook(...);  // Different bean = proxy works
    }
}

@Service
public class WebhookDeliveryService {
    @Transactional
    public void deliverWebhook(...) { }
}
Enter fullscreen mode Exit fullscreen mode

Cleaner separation, but more classes.

3. TransactionTemplate (My Choice)

Explicit, works everywhere, no magic, easy to test. This is what I went with.

The Rules

  1. @Transactional doesn't work on self-invocation - Calling this.method() bypasses the proxy
  2. @async runs in a new thread - No inherited transaction context
  3. @Modifying needs a transaction - Always, no exceptions
  4. Never leak internal errors - Sanitize before storing/displaying
  5. TransactionTemplate is your friend - When proxy magic fails, go explicit

The Debugging Checklist

When you see TransactionRequiredException:

  1. Is the method called from the same class? (self-invocation)
  2. Is the caller @Async? (new thread, no transaction)
  3. Is the method private? (proxies can't intercept)
  4. Is the class final? (no proxy possible)

Any of these = @Transactional won't work. Use TransactionTemplate.


Ever been bitten by Spring proxy magic? What's your go-to solution for transaction issues in async code?

Building jo4.io - a URL shortener that actually handles webhooks reliably.

Top comments (0)