Handling Long-Running Transactions in Spring Boot with NERV Event

What happens when an event-driven application needs to perform an operation that can take 30 minutes?

This question came up while integrating NERV Event into a realistic payment workflow.

A payment request arrives. We need to persist it, submit it to a third-party payment system, wait for validation, update our local payment state, and eventually publish the result.

The complication is that the third-party system may take 30 minutes — or potentially much longer — before producing a final result.

At first, this sounds like a transaction-management problem:

Which Spring transaction propagation should we use for the 30-minute operation?

But that's not really the right question.

The better question is:

How do we make a 30-minute business process durable without keeping a database transaction, Java thread, or event handler alive for 30 minutes?

The answer changes the architecture considerably.

A 30-minute payment isn't a 30-minute transaction.

It's a durable workflow containing several very short transactions.


1. Why a 30-Minute Database Transaction Is the Wrong Model

Consider the naive implementation:

@Transactional
public void processPayment(PaymentRequest request) {

    Payment payment = repository.save(...);

    PaymentResult result =
        paymentGateway.validate(request);

    payment.complete(result);

    eventPublisher.publish(...);
}

If paymentGateway.validate() takes 30 minutes, the transaction potentially spans the entire external operation:

BEGIN TRANSACTION
      |
      +-- INSERT payment
      |
      +-- Call payment gateway
      |       |
      |       +-- wait...
      |       +-- wait...
      |       +-- 30 minutes
      |
      +-- UPDATE payment
      |
      +-- INSERT Outbox event
      |
COMMIT

This can create several operational problems:

  • long-held database connections;
  • long-lived locks;
  • connection-pool pressure;
  • larger contention windows;
  • transaction timeouts;
  • more complicated failure recovery;
  • and poor scalability under concurrent workloads.

More importantly, the transaction does not give us the guarantee we might think it does.

Our local database cannot roll back an operation that already succeeded inside an external payment system.

A Spring database transaction does not turn an external HTTP request into part of the same ACID transaction.

So holding the transaction open for 30 minutes gives us significant cost without providing distributed atomicity.


2. The First Improvement: Short Transactions Around External Work

A better first step is to separate local database work from the external operation.

SHORT TX
+--------------------------+
| Create payment           |
| status = VALIDATING      |
|                          |
| COMMIT                   |
+--------------------------+
             |
             v
       NO TRANSACTION
+--------------------------+
| Third-party validation   |
|                          |
| 30+ minutes              |
+--------------------------+
             |
             v
SHORT TX
+--------------------------+
| Update payment           |
| SUCCESS / FAILED         |
|                          |
| Insert Outbox event      |
|                          |
| COMMIT                   |
+--------------------------+

In Spring, an orchestrator could explicitly refuse to participate in a transaction:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentTransactionService txService;
    private final PaymentGatewayClient gateway;

    @Transactional(
        propagation = Propagation.NEVER
    )
    public void processPayment(
        PaymentRequest request) {

        Long paymentId =
            txService.create(request);

        PaymentValidationResult result =
            gateway.validate(
                paymentId,
                request);

        txService.complete(
            paymentId,
            result);
    }
}

The database operations can then use short independent transactions:

@Transactional(
    propagation = Propagation.REQUIRES_NEW
)
public Long create(PaymentRequest request) {

    Payment payment = new Payment();

    payment.setStatus(
        PaymentStatus.VALIDATING);

    return repository
        .save(payment)
        .getId();
}

while the external integration explicitly avoids transaction participation:

@Transactional(
    propagation = Propagation.NOT_SUPPORTED
)
public PaymentValidationResult validate(...) {

    return paymentGatewayApi.validate(...);
}

This gives us a useful transaction policy:

Component Propagation Purpose
Long-running orchestrator NEVER Prevent the workflow from inheriting a transaction
Database operation REQUIRES_NEW Short, isolated database transaction
External gateway NOT_SUPPORTED Keep remote I/O outside database transactions

This solves the long database transaction.

But for a 30-minute operation, it still isn't enough.


3. The Reliability Gap

Suppose the payment workflow starts from a PaymentRequested event consumed through NERV Event.

NERV Event 2.1 atomically commits local Inbox handler effects together with the Inbox transition to PROCESSED.

We might therefore implement:

@NervEventHandler
public void handle(PaymentRequested event) {

    payment.setStatus(
        PaymentStatus.VALIDATION_PENDING);
}

NERV then commits:

BEGIN TRANSACTION

    payment = VALIDATION_PENDING

    Inbox(PaymentRequested)
        = PROCESSED

COMMIT

So far, this is correct.

The problem appears afterward.

Suppose we start a detached worker:

Inbox = PROCESSED
       |
       v
Gateway validation
       |
       | 30 minutes
       v
Gateway = SUCCESS
       |
       v
completePayment()
       |
       X
Database failure

We now have:

Inbox
    = PROCESSED

Local Payment
    = VALIDATION_PENDING

Payment Gateway
    = SUCCESS

Outbox
    = NOTHING

And this is the important part:

NERV Inbox will not retry PaymentRequested.

It shouldn't.

The original event was already processed successfully.

The problem is that we allowed the transition from Inbox processing to long-running work to become an in-memory handoff.


4. Every Important Handoff Must Be Durable

The Inbox handler should not merely update:

payment = VALIDATION_PENDING

It should also durably schedule the next step before the Inbox event becomes PROCESSED.

NERV Event already gives us a natural mechanism for this:

the transactional Outbox.

The Inbox transaction becomes:

BEGIN TRANSACTION

    payment =
        VALIDATION_PENDING

          +

    Outbox =
        StartPaymentValidation

          +

    Inbox(PaymentRequested) =
        PROCESSED

COMMIT

Now we have a much stronger invariant:

PaymentRequested can never become PROCESSED without durably scheduling the next step of the payment workflow.

If the transaction fails, all three changes roll back.

NERV can retry the Inbox event.

If the transaction succeeds, the next workflow step exists durably in the Outbox.


5. Publishing the Next Workflow Step

Conceptually, our Inbox handler now looks like:

@NervEventHandler
public void handle(PaymentRequested event) {

    Payment payment =
        repository.findById(
            event.paymentId())
            .orElseThrow();

    payment.setStatus(
        PaymentStatus.VALIDATION_PENDING);

    eventPublisher.publish(
        new EventPublication(
            "START_PAYMENT_VALIDATION",
            event.paymentId().toString(),
            event.paymentId().toString()
        )
    );
}

Because the handler executes inside the NERV Inbox transaction, the business-state change and Outbox insertion participate in the same local transaction as Inbox completion.

The resulting flow becomes:

PaymentRequested
       |
       v
+------------------------------+
| NERV Inbox Transaction       |
|                              |
| Payment                      |
|   -> VALIDATION_PENDING       |
|                              |
| Outbox                       |
|   + StartPaymentValidation   |
|                              |
| Inbox                        |
|   -> PROCESSED                |
+------------------------------+
       |
       v
     COMMIT
       |
       v
 NERV Outbox Dispatcher
       |
       v
StartPaymentValidation

We've now made the transition to the next workflow step durable.


6. But Don't Move the 30-Minute Transaction to Another Consumer

At this point it is tempting to create another NERV Inbox handler:

@NervEventHandler
public void validate(
    StartPaymentValidation event) {

    paymentGateway.validate(event);

    // potentially 30+ minutes
}

But that recreates the original problem somewhere else.

The NERV Inbox transaction would remain active while the handler waits for the payment gateway.

We haven't solved the long-running transaction.

We've only moved it.

Long-running external work should not execute inside a transactional event handler.


7. Treat the Payment Gateway as an Asynchronous Process

If a payment provider genuinely takes 30 minutes to determine a result, the integration should ideally be modeled asynchronously.

Instead of:

POST payment
     |
     | block for 30 minutes
     |
     v
return result

prefer:

Submit Validation
       |
       v
Gateway accepts request
       |
       v
Persist external reference
       |
       v
Return

The provider processes the payment independently.

The result arrives later through:

  • a webhook;
  • a callback;
  • a status API;
  • or durable polling.

Now no application thread needs to wait for 30 minutes.


8. Callback-Based Payment Completion

If the gateway supports callbacks, the architecture becomes:

PaymentRequested
       |
       v
NERV Inbox
       |
       | SHORT TX
       v
VALIDATION_PENDING
       +
Outbox StartValidation
       +
Inbox PROCESSED
       |
       v
     COMMIT
       |
       v
NERV Outbox
       |
       v
Validation Worker
       |
       | NO LONG DB TX
       v
Submit to Gateway
       |
       v
Gateway Processing
       |
       | 30+ minutes
       v
Webhook / Callback
       |
       v
SHORT TRANSACTION
       |
       +-- Payment = SUCCESS / FAILED
       |
       +-- Outbox = PaymentCompleted
       |
       v
     COMMIT
       |
       v
NERV Outbox
       |
       v
Kafka / SQS

The 30-minute period exists entirely outside a local database transaction.

And more importantly, the application does not need to remain alive for those 30 minutes.


9. What If the Gateway Doesn't Support Callbacks?

Then we can use durable polling.

After submitting the payment, persist:

payment.status =
    VALIDATION_PENDING

gatewayRequestId =
    ABC123

nextCheckAt =
    2026-09-16T10:30:00Z

A worker later finds records whose nextCheckAt has arrived:

VALIDATION_PENDING
       |
       v
nextCheckAt reached
       |
       v
GET gateway/status/ABC123
       |
       +-- PENDING
       |      |
       |      v
       |  update nextCheckAt
       |
       +-- SUCCESS
       |      |
       |      v
       |  complete payment
       |
       +-- FAILED
              |
              v
          fail payment

Each database update is a short transaction.

No thread sleeps for 30 minutes.

No database transaction waits for 30 minutes.

And application restarts do not destroy the workflow because the current state is persisted.


10. The Hardest Failure Window

Now consider the most interesting failure:

Payment Gateway
       |
       v
SUCCESS
       |
       v
Local completion TX
       |
       +-- payment = SUCCESS
       |
       +-- Outbox PaymentCompleted
       |
       X
Database failure
       |
       v
ROLLBACK

Locally, the transaction correctly rolls everything back:

Payment
    = VALIDATION_PENDING

Outbox
    = NOTHING

But the external system already says:

Payment Gateway
    = SUCCESS

Our database transaction cannot undo that.

This is the fundamental consistency boundary between our application and the external payment provider.


11. Idempotency and Reconciliation Close the Gap

The external payment request should use a stable idempotency identifier whenever the provider supports one.

For example:

paymentId
    |
    v
Idempotency-Key
    |
    v
Payment Gateway

We should also retain the provider's external transaction reference:

Payment
-------------------------
id
status
idempotencyKey
gatewayTransactionId
validationStartedAt
validationCompletedAt
nextCheckAt

If our local completion transaction fails, we don't blindly create another payment.

Instead, we reconcile:

Local Payment
    = VALIDATION_PENDING
          |
          v
Query Gateway
using gatewayTransactionId
          |
          v
Gateway says SUCCESS
          |
          v
REQUIRES_NEW
+---------------------------+
| payment = SUCCESS         |
|                           |
| gatewayRef = ABC123       |
|                           |
| Outbox                    |
|   + PaymentCompleted      |
|                           |
| COMMIT                    |
+---------------------------+

If that transaction fails again, reconciliation can retry again.

The external payment is not duplicated because we're referring to the same logical operation.


12. NERV Event Handles the Local Atomicity Boundary

Once we know the external result, NERV Event gives us a strong local guarantee.

The final transaction contains:

BEGIN TRANSACTION

    payment.status =
        SUCCESS

          +

    payment.gatewayRef =
        ABC123

          +

    Outbox =
        PaymentCompleted

COMMIT

This means we cannot commit:

Payment = SUCCESS

without also durably recording:

PaymentCompleted

for eventual delivery.

If Kafka or SQS is unavailable afterward, that is no longer the payment transaction's problem.

The Outbox record already exists.

NERV Event can retry delivery independently.

Payment SUCCESS
       |
       v
Outbox PaymentCompleted
       |
       | broker unavailable
       X
       |
       | retry
       | retry
       v
Kafka / SQS

13. Three Different Failure Boundaries, Three Different Mechanisms

This payment workflow reveals an important architectural principle.

There isn't one reliability mechanism that solves every failure.

There are several boundaries:

Boundary Failure Mechanism
Inbox + local handler effects Handler or DB failure NERV Inbox transaction + retry
Workflow step handoff Application crashes after Inbox completion Transactional Outbox
Application + payment gateway Unknown or externally committed result Idempotency + reconciliation
Payment state + completion event Local DB failure Database transaction + NERV Outbox
Outbox + Kafka/SQS Broker unavailable NERV durable dispatch + retry

Trying to solve all of these with one giant transaction is both unrealistic and unnecessary.


14. The Complete Durable Payment Workflow

Putting everything together gives us:

PaymentRequested
       |
       v
+-------------------------------+
| NERV Inbox TX                 |
|                               |
| Payment                       |
|   -> VALIDATION_PENDING        |
|                               |
| Outbox                        |
|   + StartPaymentValidation    |
|                               |
| Inbox                         |
|   -> PROCESSED                 |
+-------------------------------+
       |
       v
     COMMIT
       |
       v
+-------------------------------+
| NERV Outbox                   |
|                               |
| durable delivery              |
+-------------------------------+
       |
       v
StartPaymentValidation
       |
       v
+-------------------------------+
| Submit to Payment Gateway     |
|                               |
| idempotencyKey = paymentId    |
|                               |
| NO LONG DB TRANSACTION        |
+-------------------------------+
       |
       v
 Gateway processing
       |
       | 30+ minutes
       |
       +----------------------+
       |                      |
       v                      v
    callback             durable polling
       |                      |
       +----------+-----------+
                  |
                  v
          Gateway Result
                  |
                  v
+--------------------------------+
| SHORT COMPLETION TX            |
|                                |
| Payment = SUCCESS / FAILED     |
|                                |
| Save gateway reference         |
|                                |
| Outbox                         |
|   + PaymentCompleted           |
+--------------------------------+
                  |
                  v
                COMMIT
                  |
                  v
+--------------------------------+
| NERV Outbox                    |
|                                |
| Kafka / SQS                    |
+--------------------------------+

Notice what is missing from this architecture:

There is no 30-minute database transaction.

There isn't even necessarily a 30-minute Java method.

Instead, we have a sequence of durable state transitions.


15. Transaction Boundaries Should Follow Consistency Boundaries

This is the broader lesson.

A Java method might represent one business operation:

processPayment()

but that does not mean one database transaction should span the entire business operation.

The transaction boundary should follow what the local database can actually guarantee atomically.

For example:

Local state
    +
Outbox event
    =
one transaction

and:

Inbox completion
    +
local handler effects
    +
next durable workflow step
    =
one transaction

But:

Local database
    +
30-minute remote operation

is not one useful ACID boundary.

Transaction boundaries should follow consistency boundaries, not method boundaries.


16. This Pattern Isn't Just for Payments

The same architecture applies whenever external work is slow, unpredictable, or asynchronous:

  • payment authorization;
  • fraud analysis;
  • identity verification;
  • document processing;
  • AI inference;
  • large file processing;
  • third-party provisioning;
  • shipping operations;
  • external approval workflows;
  • and long-running integrations.

The common structure is:

SHORT TX
    |
    v
DURABLE STATE
    |
    v
DURABLE HANDOFF
    |
    v
EXTERNAL WORK
    |
    v
RECONCILIATION
    |
    v
SHORT TX
    |
    v
DURABLE EVENT

The Bigger Lesson

When we talk about reliable event-driven systems, it's easy to focus entirely on whether messages are delivered.

But reliable delivery is only part of the problem.

We also need to ask:

  • What happens if the application crashes between workflow steps?
  • What happens if the external operation succeeds but our database fails?
  • What happens if we don't know whether the external operation succeeded?
  • What happens if Kafka is unavailable after the payment commits?
  • What happens if the application restarts during the 30-minute wait?

A reliable design needs an answer for each boundary.

Short Transactions
        +
Durable State
        +
Durable Handoffs
        +
NERV Inbox / Outbox
        +
External Idempotency
        +
Reconciliation
        =
Reliable Long-Running Workflows

And that brings us back to the central idea:

A 30-minute payment isn't a 30-minute transaction. It's a durable workflow composed of several very short transactions.

NERV Event doesn't try to make a remote payment provider part of your local database transaction.

Instead, it helps make the boundaries where strong guarantees are possible durable, atomic, retryable, and observable.

For real-world distributed systems, that's the guarantee that matters.


Try NERV Event

NERV Event is an open-source Spring Boot library for building reliable event-driven applications using transactional Outbox and Inbox patterns.

It provides infrastructure for:

  • transactional Outbox publishing;
  • durable Inbox consumption;
  • atomic Inbox handler processing;
  • idempotent event processing;
  • automatic retries and failure tracking;
  • multi-instance-safe claiming;
  • aggregate-aware ordering;
  • Kafka integration;
  • Amazon SQS integration;
  • scheduler resilience;
  • and operational inspection.

GitHub: https://github.com/czetsuyatech/nerv-event

If you're building long-running workflows with Spring Boot, I'd be interested to hear how you're handling durable handoffs, external idempotency, and reconciliation — particularly for payments and other slow third-party operations.

If NERV Event is useful to you, consider starring the repository. Feedback, issues, and contributions are always welcome.


NERV — Next-Generation Engineering for Runtime Velocity

Production-ready infrastructure for Java engineers building reliable enterprise systems.

0 Comments

Post a Comment

Post a Comment (0)

Previous Post Next Post
NERV Open Source

Building production Spring Boot systems?

Explore NERV — open-source Java libraries for audit trails, persistence, exception handling, and reliable event-driven architecture.

Explore NERV on GitHub →