Skip to content

How to Propagate XID Between Spring Boot Services with Apache Seata

Problem

When I implemented distributed transactions with Apache Seata in our microservices architecture, I encountered a silent failure. Service A started a @GlobalTransactional, called Service B via HTTP, but Service B ran in a separate transaction context instead of joining the global transaction.

The worst part? No error message. The transaction just silently failed to rollback across services when something went wrong.

Output
Service A: Started global transaction, XID=192.168.1.100:8091:123456789
Service A: Calling Service B via HTTP...
Service B: Processing order...
Service B: Local transaction committed successfully
Service A: Exception occurred! Rolling back...
Service A: Global transaction rolled back
Service B: Data NOT rolled back! Still exists in database!

Why didn’t Service B participate in the global transaction?

Environment

  • Spring Boot 3.2.x
  • Apache Seata 2.0.x
  • Java 17
  • RestClient (Spring 6.1+)

Root Cause

After hours of debugging, I discovered that Seata propagates the distributed transaction context via the XID (Transaction ID) passed in HTTP headers. Without the XID header, each service runs in isolation.

┌─────────────┐ ┌─────────────┐
│ Service A │ │ Service B │
│ │ │ │
│ XID: abc │ ─── HTTP Call ───▶ │ XID: null │
│ │ (No XID header) │ │
│ │ │ │
└─────────────┘ └─────────────┘
Service B has no idea it should join the global transaction!

The XID needs to be passed in the HTTP header named TX_XID (defined as RootContext.KEY_XID). I needed two things:

  1. Outbound: Add XID to outgoing HTTP requests
  2. Inbound: Extract XID from incoming HTTP requests and bind to RootContext

First Attempt: Add XID to Outgoing Requests

I created a ClientHttpRequestInterceptor to add the XID header to all outgoing HTTP requests:

SeataXidClientInterceptor.java
import io.seata.core.context.RootContext;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StringUtils;
import java.io.IOException;
public class SeataXidClientInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String xid = RootContext.getXID();
if (StringUtils.hasText(xid)) {
request.getHeaders().add(RootContext.KEY_XID, xid);
}
return execution.execute(request, body);
}
}

Then I registered the interceptor with RestClient:

RestClientConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
return RestClient.builder()
.requestInterceptor(new SeataXidClientInterceptor())
.build();
}
}

I tested the outbound propagation:

Output
Service A: Started global transaction, XID=192.168.1.100:8091:123456789
Service A: HTTP request header TX_XID=192.168.1.100:8091:123456789
Service A: Calling Service B...

The XID is now being sent! But Service B still wasn’t joining the transaction. The incoming request wasn’t being processed correctly.

Second Attempt: Extract XID on Incoming Requests

Service B needs to extract the XID from the incoming HTTP request and bind it to RootContext. I created a servlet Filter:

SeataXidFilter.java
import io.seata.core.context.RootContext;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SeataXidFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) req;
String xid = httpRequest.getHeader(RootContext.KEY_XID);
boolean bound = false;
if (StringUtils.hasText(xid) && !xid.equals(RootContext.getXID())) {
RootContext.bind(xid);
bound = true;
}
try {
chain.doFilter(req, res);
} finally {
if (bound) {
RootContext.unbind();
}
}
}
}

I deployed Service B with the filter and tested again:

Output
Service A: Started global transaction, XID=192.168.1.100:8091:123456789
Service A: Calling Service B...
Service B: Received XID header=192.168.1.100:8091:123456789
Service B: XID bound to RootContext
Service B: Processing order...
Service B: Database operation within global transaction
Service A: Exception occurred! Rolling back...
Service A: Global transaction rolled back
Service B: Transaction rolled back successfully!

Both services now participate in the same global transaction.

Why Two-Way Propagation?

I wondered why both outbound and inbound propagation were necessary. Here’s what I learned:

Outbound Interceptor Inbound Filter
────────────────── ───────────────
Service A ┌─────────────────┐ Service B
┌──────────────┐ │ Add XID header │ ┌──────────────────────┐
│ │ │ to request │ │ │
│ RootContext │ ───┤─────────────────┼───▶│ Extract XID header │
│ XID: abc │ │ │ │ │
│ │ │ │ │ Bind to RootContext │
└──────────────┘ └─────────────────┘ │ XID: abc │
└──────────────────────┘
Without either piece, the transaction context is lost.

Outbound Interceptor:

  • Reads XID from current thread’s RootContext
  • Adds it to HTTP request headers
  • Ensures XID travels with the request

Inbound Filter:

  • Reads XID from HTTP request headers
  • Binds it to current thread’s RootContext
  • Ensures downstream operations run in the same transaction

What About RestTemplate and WebClient?

If you’re using RestTemplate instead of RestClient, Spring Cloud Alibaba Seata provides automatic propagation. But I learned this only works if you have the Spring Cloud Alibaba dependency:

pom.xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

For RestClient and WebClient, manual configuration is still required even with Spring Cloud Alibaba.

Here’s the configuration for WebClient:

WebClientConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.filter((request, next) -> {
String xid = RootContext.getXID();
if (StringUtils.hasText(xid)) {
request = ClientRequest.from(request)
.header(RootContext.KEY_XID, xid)
.build();
}
return next.exchange(request);
})
.build();
}
}

Common Pitfalls

Pitfall 1: Wrong Filter Order

WrongFilterOrder.java
// WRONG: Filter runs after transaction starts
@Component
public class SeataXidFilter implements Filter {
// This runs too late!
}

Always use @Order(Ordered.HIGHEST_PRECEDENCE) to ensure the filter runs first.

Pitfall 2: Forgetting to Unbind

MemoryLeakFilter.java
// WRONG: XID stays bound to thread
if (StringUtils.hasText(xid)) {
RootContext.bind(xid);
}
chain.doFilter(req, res);
// Forgot to unbind! Thread pool reuse causes issues.

Always unbind in the finally block to prevent memory leaks in thread pools.

Pitfall 3: Async Calls

For async calls with @Async or CompletableFuture, the XID doesn’t automatically propagate to new threads:

AsyncService.java
@Service
public class AsyncService {
@Async
public void asyncOperation() {
// XID is null here!
// Need manual propagation
}
}

You need to manually capture and pass the XID:

AsyncServiceFixed.java
@Service
public class AsyncService {
@Async
public void asyncOperation(String xid) {
RootContext.bind(xid);
try {
// Now XID is available
} finally {
RootContext.unbind();
}
}
}
// Caller
String xid = RootContext.getXID();
asyncService.asyncOperation(xid);

Summary

XID propagation is essential for Seata distributed transactions across microservices. The solution requires two parts:

  1. Outbound: Implement ClientHttpRequestInterceptor to add XID to outgoing HTTP requests
  2. Inbound: Implement servlet Filter to extract and bind XID from incoming HTTP requests

Spring Cloud Alibaba Seata handles this automatically for RestTemplate, but RestClient and WebClient require manual setup. Always use HIGHEST_PRECEDENCE for the filter order and remember to unbind XID in the finally block.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments